okstra 0.173.0 → 0.174.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 (62) hide show
  1. package/docs/architecture/storage-model.md +13 -3
  2. package/docs/architecture.md +5 -21
  3. package/docs/cli.md +3 -2
  4. package/docs/container.md +1 -1
  5. package/docs/contributor-change-matrix.md +1 -1
  6. package/docs/project-structure-overview.md +13 -13
  7. package/docs/task-process/README.md +1 -1
  8. package/docs/task-process/implementation-planning.md +1 -1
  9. package/package.json +1 -1
  10. package/runtime/BUILD.json +2 -2
  11. package/runtime/agents/workers/claude-worker.md +1 -1
  12. package/runtime/bin/lib/okstra/globals.sh +1 -1
  13. package/runtime/bin/okstra-provider-exec.py +29 -12
  14. package/runtime/bin/okstra-trace-cleanup.sh +58 -129
  15. package/runtime/prompts/lead/adapters/cmux.md +2 -0
  16. package/runtime/prompts/lead/okstra-lead-contract.md +1 -1
  17. package/runtime/prompts/lead/plan-body-verification.md +3 -3
  18. package/runtime/prompts/lead/report-writer.md +6 -6
  19. package/runtime/prompts/profiles/_common-contract.md +2 -2
  20. package/runtime/prompts/profiles/_implementation-executor.md +2 -0
  21. package/runtime/prompts/profiles/_implementation-verifier.md +2 -2
  22. package/runtime/prompts/profiles/error-analysis.md +1 -1
  23. package/runtime/prompts/profiles/implementation-planning.md +12 -9
  24. package/runtime/prompts/profiles/implementation.md +2 -1
  25. package/runtime/prompts/profiles/release-handoff.md +1 -1
  26. package/runtime/python/okstra_ctl/adapters/dispatch/__init__.py +1 -6
  27. package/runtime/python/okstra_ctl/adapters/hosts/external/relay.md +4 -4
  28. package/runtime/python/okstra_ctl/adapters/providers/claude/adapter.py +5 -0
  29. package/runtime/python/okstra_ctl/conformance.py +68 -0
  30. package/runtime/python/okstra_ctl/dispatch_core.py +89 -39
  31. package/runtime/python/okstra_ctl/dispatch_state.py +142 -14
  32. package/runtime/python/okstra_ctl/doctor.py +2 -2
  33. package/runtime/python/okstra_ctl/domain/worker_exec.py +5 -0
  34. package/runtime/python/okstra_ctl/final_report_schema.py +5 -4
  35. package/runtime/python/okstra_ctl/pane_reclaim.py +13 -22
  36. package/runtime/python/okstra_ctl/render_final_report.py +15 -19
  37. package/runtime/python/okstra_ctl/report_contract.py +0 -1
  38. package/runtime/python/okstra_ctl/report_finalize.py +68 -9
  39. package/runtime/python/okstra_ctl/run.py +43 -2
  40. package/runtime/python/okstra_ctl/schema_excerpt.py +1 -1
  41. package/runtime/python/okstra_ctl/scope_provenance.py +1 -1
  42. package/runtime/python/okstra_ctl/session.py +69 -12
  43. package/runtime/python/okstra_ctl/team.py +51 -25
  44. package/runtime/python/okstra_ctl/tmux.py +19 -149
  45. package/runtime/python/okstra_ctl/worker_request.py +2 -0
  46. package/runtime/python/okstra_ctl/worktree.py +69 -3
  47. package/runtime/python/okstra_token_usage/cli.py +1 -1
  48. package/runtime/python/okstra_token_usage/collect.py +66 -6
  49. package/runtime/skills/okstra-setup/references/project-config.md +11 -0
  50. package/runtime/templates/reports/settings.template.json +0 -24
  51. package/runtime/validators/lib/fixtures.sh +49 -17
  52. package/runtime/validators/validate-implementation-plan-stages.py +63 -3
  53. package/runtime/validators/validate-run.py +14 -473
  54. package/runtime/validators/validate_session_conformance.py +1 -1
  55. package/src/cli-registry.mjs +8 -1
  56. package/src/commands/execute/team.mjs +3 -3
  57. package/src/commands/execute/worktree-status.mjs +109 -0
  58. package/src/commands/lifecycle/install.mjs +0 -2
  59. package/src/commands/report/finalize.mjs +13 -6
  60. package/runtime/bin/okstra-subagent-reclaim.sh +0 -26
  61. package/runtime/schemas/final-report-v1.0.schema.json +0 -6366
  62. package/runtime/templates/reports/final-report.template.md +0 -1258
@@ -1,7 +1,7 @@
1
1
  """Mini JSON Schema validator for the final-report data.json.
2
2
 
3
3
  This is a deliberately narrow JSON Schema implementation that supports
4
- exactly the keywords used by ``schemas/final-report-v1.0.schema.json``:
4
+ exactly the keywords used by ``schemas/final-report-v2.0.schema.json``:
5
5
 
6
6
  type, required, properties, additionalProperties, enum, const, pattern,
7
7
  minLength, minItems, maxItems, uniqueItems, items, minimum, maximum, $ref ($defs),
@@ -23,6 +23,8 @@ import re
23
23
  from pathlib import Path
24
24
  from typing import Any
25
25
 
26
+ from okstra_ctl.report_contract import CURRENT_REPORT_SCHEMA_VERSION
27
+
26
28
 
27
29
  class SchemaError(ValueError):
28
30
  """Raised when a schema itself is malformed (e.g. a $ref points
@@ -260,7 +262,6 @@ def validate(data: Any, schema: dict) -> list[str]:
260
262
 
261
263
 
262
264
  SCHEMA_FILENAMES = {
263
- "1.0": "final-report-v1.0.schema.json",
264
265
  "2.0": "final-report-v2.0.schema.json",
265
266
  }
266
267
 
@@ -295,9 +296,9 @@ def load_schema_for_data(data: dict, start: Path | None = None) -> dict:
295
296
 
296
297
  def load_schema(schema_path: Path | None = None) -> dict:
297
298
  """Load the final-report schema. If ``schema_path`` is None, locate
298
- ``schemas/final-report-v1.0.schema.json`` relative to this file's
299
+ ``schemas/final-report-v2.0.schema.json`` relative to this file's
299
300
  repo root.
300
301
  """
301
302
  if schema_path is not None:
302
303
  return json.loads(Path(schema_path).read_text(encoding="utf-8"))
303
- return load_schema_version("1.0")
304
+ return load_schema_version(CURRENT_REPORT_SCHEMA_VERSION)
@@ -1,5 +1,13 @@
1
- """완료(terminal) worker 판정 — pane 회수용. 판정 SSOT 는
2
- wrapper_status.read_wrapper_status 이며 여기서 재사용한다."""
1
+ """활성 run 스코프 조회 — pane 정리 의무를 어느 run 에 걸지 고르는 데 쓴다.
2
+
3
+ 호출자는 `SessionStart(compact)` 훅(`okstra-compact-reminder.sh`) 하나다. 압축
4
+ 직후 리드에게 "이 run 의 완료 teammate pane 을 라운드 경계마다 회수하라"는 의무를
5
+ 재주입할 때, 그 대상이 되는 이 프로젝트의 진행 중 run 을 여기서 찾는다.
6
+
7
+ 완료 판정(`is_completed_status`)과 프로젝트 무관 전체 조회(`active_run_dirs`)도
8
+ 여기 있었으나, 그것을 쓰던 `okstra-trace-cleanup.sh --reclaim-completed` 와
9
+ `okstra-subagent-reclaim.sh` 가 제거되면서 함께 사라졌다.
10
+ """
3
11
  from __future__ import annotations
4
12
 
5
13
  import json
@@ -8,12 +16,6 @@ from pathlib import Path
8
16
 
9
17
  from .paths import resolve_under_root
10
18
  from .reconcile import NON_TERMINAL_RECENT_STATUSES
11
- from .wrapper_status import read_wrapper_status
12
-
13
-
14
- def is_completed_status(status_path: str) -> bool:
15
- status = read_wrapper_status(Path(status_path))
16
- return status is not None and status.is_terminal
17
19
 
18
20
 
19
21
  def _iter_active_run_dirs(home: Path):
@@ -30,9 +32,9 @@ def _iter_active_run_dirs(home: Path):
30
32
  row = json.loads(line)
31
33
  except json.JSONDecodeError:
32
34
  continue
33
- # 회수 대상(진행 중) 집합은 reconcile 의 NON_TERMINAL_RECENT_STATUSES 가
34
- # SSOT. reserving 은 아직 run-dir 산출물이 없어(= 회수할 pane 이 없어) 이
35
- # 집합에 들어있지 않으므로 자연히 제외된다(allowlist).
35
+ # 대상(진행 중) 집합은 reconcile 의 NON_TERMINAL_RECENT_STATUSES 가
36
+ # SSOT. reserving 은 아직 run-dir 산출물이 없어 이 집합에 들어있지
37
+ # 않으므로 자연히 제외된다(allowlist).
36
38
  if row.get("status") not in NON_TERMINAL_RECENT_STATUSES:
37
39
  continue
38
40
  run_dir = resolve_under_root(row.get("projectRoot"), row.get("runDirRel"))
@@ -40,11 +42,6 @@ def _iter_active_run_dirs(home: Path):
40
42
  yield row.get("projectRoot"), run_dir
41
43
 
42
44
 
43
- def active_run_dirs(home: Path) -> list[Path]:
44
- """active.jsonl 의 진행 중 run 들의 절대 run-dir 목록."""
45
- return [run_dir for _project_root, run_dir in _iter_active_run_dirs(home)]
46
-
47
-
48
45
  def active_run_dirs_for_project(home: Path, cwd: Path) -> list[Path]:
49
46
  """진행 중 run 중 projectRoot 이 cwd(또는 그 상위)인 것의 절대 run-dir 목록.
50
47
  SessionStart(compact) 훅이 이 프로젝트의 run 에만 리마인더를 걸도록 스코프한다."""
@@ -63,16 +60,10 @@ def active_run_dirs_for_project(home: Path, cwd: Path) -> list[Path]:
63
60
 
64
61
 
65
62
  def main(argv: list[str]) -> int:
66
- if len(argv) == 2 and argv[0] == "--active-dirs":
67
- for run_dir in active_run_dirs(Path(argv[1])):
68
- print(run_dir)
69
- return 0
70
63
  if len(argv) == 3 and argv[0] == "--active-dirs-for":
71
64
  for run_dir in active_run_dirs_for_project(Path(argv[1]), Path(argv[2])):
72
65
  print(run_dir)
73
66
  return 0
74
- if len(argv) == 1:
75
- return 0 if is_completed_status(argv[0]) else 1
76
67
  return 1
77
68
 
78
69
 
@@ -12,10 +12,10 @@ columns in the Execution Status table, omitted §4 phase-continuation
12
12
  rows, ad-hoc ``## Index`` sections. Routing everything through one
13
13
  template + schema cuts those failure modes to zero.
14
14
 
15
- For schema-v1 compatibility, the top-of-report ``## Index`` remains a
16
- deterministic post-render section built by ``_inject_index_and_anchors``.
17
- Schema v2 keeps its compact fixed AI-handoff order and does not inject the
18
- legacy reader index; the task-specific HTML provides the human navigation.
15
+ Rendering never injects a reader index: the AI-handoff markdown keeps its
16
+ compact fixed order and the task-specific HTML provides human navigation.
17
+ ``_inject_index_and_anchors`` survives as a standalone pass over an already
18
+ written markdown file, driven by ``scripts/okstra-inject-report-index.py``.
19
19
 
20
20
  Phase 7 mutation flow: ``okstra-token-usage.py --substitute-data`` fills
21
21
  the ``tokenUsage`` and ``executionStatus[].totalTokens`` etc. cells in
@@ -53,7 +53,11 @@ from okstra_ctl.i18n import I18nError, SUPPORTED_LANGS, load_dictionary, make_ji
53
53
  from okstra_ctl.md_table import UNESCAPED_PIPE_RE, to_cell_text
54
54
  from okstra_ctl.models import UnknownModelError, resolve_model_metadata
55
55
  from okstra_ctl.paths import find_asset_root
56
- from okstra_ctl.report_contract import TASK_TYPE_DATA_PROPERTY, markdown_template_for
56
+ from okstra_ctl.report_contract import (
57
+ CURRENT_REPORT_SCHEMA_VERSION,
58
+ TASK_TYPE_DATA_PROPERTY,
59
+ markdown_template_for,
60
+ )
57
61
  from okstra_ctl.report_markdown import ReportSections
58
62
  from okstra_ctl.schema_excerpt import excerpt_cut_from_version
59
63
  from okstra_ctl.seeding import installed_version
@@ -61,10 +65,9 @@ from okstra_ctl.usage_cells import format_duration_ms, format_int, format_usd
61
65
 
62
66
 
63
67
  TEMPLATE_BY_SCHEMA_VERSION = {
64
- "1.0": ("templates", "reports", "final-report.template.md"),
65
68
  "2.0": ("templates", "reports", "final-report-v2.template.md"),
66
69
  }
67
- DEFAULT_TEMPLATE_REL = TEMPLATE_BY_SCHEMA_VERSION["1.0"]
70
+ DEFAULT_TEMPLATE_REL = TEMPLATE_BY_SCHEMA_VERSION[CURRENT_REPORT_SCHEMA_VERSION]
68
71
 
69
72
  TASK_DELIVERABLE_TITLES = {
70
73
  "requirements-discovery": "Requirements Discovery",
@@ -666,14 +669,7 @@ def render(
666
669
 
667
670
  try:
668
671
  template = env.get_template(template_path.name)
669
- context = (
670
- _ai_markdown_context(data, schema)
671
- if data.get("schemaVersion") == "2.0"
672
- else _with_optional_defaults(data)
673
- )
674
- rendered = template.render(**context)
675
- if data.get("schemaVersion") == "1.0":
676
- rendered = _inject_index_and_anchors(rendered, dictionary)
672
+ rendered = template.render(**_ai_markdown_context(data, schema))
677
673
  return _ventilate_prose(rendered)
678
674
  except I18nError as exc:
679
675
  raise FinalReportRenderError(
@@ -689,10 +685,10 @@ def find_default_template(start: Path | None = None) -> Path:
689
685
  """Locate the bundled final-report template.
690
686
 
691
687
  Resolution order:
692
- 1. ``$OKSTRA_HOME/templates/reports/final-report.template.md`` (installed runtime).
693
- 2. ``<repo>/templates/reports/final-report.template.md`` (in-repo dev runs).
688
+ 1. ``$OKSTRA_HOME/templates/reports/final-report-v2.template.md`` (installed runtime).
689
+ 2. ``<repo>/templates/reports/final-report-v2.template.md`` (in-repo dev runs).
694
690
  Repo root is detected by walking up from this file until a
695
- ``templates/reports/final-report.template.md`` exists.
691
+ ``templates/reports/final-report-v2.template.md`` exists.
696
692
 
697
693
  Raises ``FinalReportRenderError`` if neither path is present.
698
694
  """
@@ -701,7 +697,7 @@ def find_default_template(start: Path | None = None) -> Path:
701
697
  return root.joinpath(*DEFAULT_TEMPLATE_REL)
702
698
 
703
699
  raise FinalReportRenderError(
704
- "could not locate final-report.template.md. Set OKSTRA_HOME or "
700
+ "could not locate final-report-v2.template.md. Set OKSTRA_HOME or "
705
701
  "run from a checkout that contains templates/reports/."
706
702
  )
707
703
 
@@ -3,7 +3,6 @@ from __future__ import annotations
3
3
 
4
4
 
5
5
  CURRENT_REPORT_SCHEMA_VERSION = "2.0"
6
- LEGACY_REPORT_SCHEMA_VERSION = "1.0"
7
6
 
8
7
  PUBLIC_REPORT_TASK_TYPES = (
9
8
  "requirements-discovery",
@@ -6,6 +6,18 @@ substitution, html view rendering, follow-up task spawning, and run validation.
6
6
  The order is load-bearing — rendering before substitution ships `--` token
7
7
  cells, and validating before rendering trips the report-views contract.
8
8
 
9
+ `token-usage` is the one step whose failure does not stop the sequence, which
10
+ deliberately accepts that first state: its input is the lead session log, so a
11
+ refusal there would otherwise delete every artifact the later steps produce.
12
+ The run does not pass in that state. `validators/validate-run.py` re-collects
13
+ when the recorded usage is all zeros against an `unavailable` session source
14
+ (`_needs_token_autofix`) and refuses the run rather than ship zeroed counts
15
+ (`accuracy-failed`); a legacy v1 report is caught earlier still, by its
16
+ unsubstituted `{{...}}` placeholders, which a v2 report never carries because
17
+ its numeric cells are `null` until this step fills them. Substituting the
18
+ tokens on a later retry then leaves the already-rendered html stale for
19
+ `validators/validate-report-views.py`.
20
+
9
21
  The translation sidecar is NOT one of these steps. `render-views` overlays it,
10
22
  so a non-English run dispatches the translator before this sequence starts —
11
23
  after verifying the data.json is English, which is why `check-source` is also
@@ -29,6 +41,7 @@ from .agent_activity import ActivityProjectionError, project_agent_activity
29
41
  from .dispatch_state import DispatchError, link_agent_dispatch_result
30
42
  from .final_report_paths import final_report_data_path, final_report_markdown_path
31
43
  from .paths import task_dir, task_manifest_file
44
+ from .session import observe_lead_session
32
45
 
33
46
 
34
47
  STEP_PROJECT_ACTIVITY = "project-activity"
@@ -354,7 +367,14 @@ def run_finalize(
354
367
  before_step: Callable[[str], None] | None = None,
355
368
  only: Sequence[str] | None = None,
356
369
  ) -> dict[str, Any]:
357
- """Run the Phase 7 steps in order, stopping at the first non-zero exit.
370
+ """Run the Phase 7 steps in contractual order.
371
+
372
+ A non-zero exit stops the sequence, with one exception: ``token-usage``
373
+ defers. Its input is the lead session log — state outside this run — so a
374
+ refusal there is not evidence that the artifacts after it are unwritable.
375
+ The deferred failure becomes the result's ``reason`` when nothing later
376
+ fails, and yields to any later failure, which is the step that actually
377
+ blocked the run.
358
378
 
359
379
  ``before_step`` fires immediately before each step is spawned, letting an
360
380
  adapter settle state the step will read (the Codex adapter marks the report
@@ -366,6 +386,7 @@ def run_finalize(
366
386
  step at full token and wall-clock cost.
367
387
  """
368
388
  steps: list[dict[str, Any]] = []
389
+ deferred = ""
369
390
  try:
370
391
  commands = build_commands(ctx)
371
392
  except FinalizeError as exc:
@@ -426,11 +447,21 @@ def run_finalize(
426
447
  )
427
448
  steps.append(step_payload(name, command, result))
428
449
  if result.returncode != 0:
429
- return {
430
- "ok": False,
431
- "reason": f"{name} failed with exit code {result.returncode}",
432
- "steps": steps,
433
- }
450
+ failure = f"{name} failed with exit code {result.returncode}"
451
+ # Token collection reads the lead session log, which lives outside
452
+ # this run. One refusal there (`grandTotalTokens=0`) took the whole
453
+ # run's output with it: html never rendered, follow-ups never
454
+ # spawned, and `validate-run` then blocked on `report-views:
455
+ # missing html artifact`. So this step alone defers instead of
456
+ # stopping the sequence. It hides nothing — the non-zero exit stays
457
+ # in `steps`, `ok` stays False, and the closing `validate-run`
458
+ # still refuses the run.
459
+ if name == STEP_TOKEN_USAGE:
460
+ deferred = failure
461
+ continue
462
+ return {"ok": False, "reason": failure, "steps": steps}
463
+ if deferred:
464
+ return {"ok": False, "reason": deferred, "steps": steps}
434
465
  return {"ok": True, "reason": "", "steps": steps}
435
466
 
436
467
 
@@ -546,6 +577,29 @@ def _step_summary_lines(result: Mapping[str, Any]) -> list[str]:
546
577
  return lines
547
578
 
548
579
 
580
+ def _recovery_step_names(result: Mapping[str, Any]) -> list[str]:
581
+ """The steps a retry has to re-run: every step from the earliest failure on.
582
+
583
+ Naming only the failed steps would prescribe half a recovery. `token-usage`
584
+ defers, so `render-views` already wrote an html view from unsubstituted
585
+ data; substituting the tokens on a retry leaves that view stale
586
+ (`validators/validate-report-views.py` checks `source-sha256` against the
587
+ md body). And a sequence that stopped early never reached `validate-run`,
588
+ which is the step that decides whether the run is shippable. Resuming from
589
+ the earliest failure redoes both while still skipping the prefix that
590
+ succeeded — the saving `--only` exists for.
591
+ """
592
+ failed = {
593
+ string_value(step.get("name"))
594
+ for step in (result.get("steps") or [])
595
+ if step.get("exitCode") != 0
596
+ }
597
+ for index, name in enumerate(STEP_ORDER):
598
+ if name in failed:
599
+ return list(STEP_ORDER[index:])
600
+ return []
601
+
602
+
549
603
  def main(argv: Sequence[str] | None = None) -> int:
550
604
  args = _parser().parse_args(argv)
551
605
  try:
@@ -553,6 +607,10 @@ def main(argv: Sequence[str] | None = None) -> int:
553
607
  except FinalizeError as exc:
554
608
  print(f"error: {exc}", file=sys.stderr)
555
609
  return 2
610
+ # Phase 7 is the last boundary the lead crosses, and the last chance to add
611
+ # the generations resume and compaction split it into. It runs ahead of the
612
+ # sequence because the token-usage step below reads `leadSessionIds`.
613
+ observe_lead_session(ctx.project_root, ctx.team_state_path)
556
614
  result = run_finalize(ctx, only=args.only or None)
557
615
  print(json.dumps(result, indent=2, ensure_ascii=False))
558
616
  print("finalize steps:", file=sys.stderr)
@@ -560,10 +618,11 @@ def main(argv: Sequence[str] | None = None) -> int:
560
618
  print(line, file=sys.stderr)
561
619
  if not result["ok"]:
562
620
  print(f"error: {result['reason']}", file=sys.stderr)
563
- if result.get("steps"):
621
+ recovery = _recovery_step_names(result)
622
+ if recovery:
623
+ flags = " ".join(f"--only {name}" for name in recovery)
564
624
  print(
565
- "retry just the failing step with "
566
- f"`--only {result['steps'][-1].get('name')}`",
625
+ f"resume the sequence from the earliest failure with `{flags}`",
567
626
  file=sys.stderr,
568
627
  )
569
628
  return 1
@@ -125,7 +125,11 @@ from .render import (
125
125
  )
126
126
  from okstra_project.dirs import okstra_home
127
127
 
128
- from .dispatch_state import BACKEND_CMUX_PANE, detect_terminal_backend
128
+ from .dispatch_state import (
129
+ BACKEND_CMUX_PANE,
130
+ detect_terminal_backend,
131
+ generate_claude_session_id,
132
+ )
129
133
  from .run_context import (
130
134
  compute_and_write_run_context,
131
135
  refresh_run_context_snapshot,
@@ -139,7 +143,6 @@ from .seeding import (
139
143
  verify_installation,
140
144
  )
141
145
  from .session import (
142
- generate_claude_session_id,
143
146
  resolve_inproc_lead_session_id,
144
147
  write_claude_resume_command_file,
145
148
  )
@@ -296,6 +299,43 @@ def _validate_data_json_approval_consistency(
296
299
  )
297
300
 
298
301
 
302
+ def _validate_approved_plan_conformance(path: Path) -> None:
303
+ """승인 계획의 stage conformance 선언 형식을 승인 경계에서 판정한다.
304
+
305
+ 같은 판정이 지금까지는 구현 런의 마지막 `validate-run` 에서만 나왔다. 그때는
306
+ 워커 배치와 수렴이 이미 끝난 뒤라 런 하나를 통째로 버려야 했다 — 실측된
307
+ 실패에서 승인 계획의 stage 2~7 이 기계 형식이 아니라 산문이었다.
308
+
309
+ 선언이 없는 stage 는 건드리지 않는다(`conformance.malformed_conformance_stages`
310
+ 참조): XOR 부재는 계획 단계의 check S11 이 이미 막고, 구현 진입 게이트도
311
+ validate-run 이 계속 요구한다. 여기서 다시 하면 같은 규칙이 두 곳이 된다.
312
+ """
313
+ from .conformance import malformed_conformance_stages
314
+
315
+ loaded = _load_final_report_data_if_present(path)
316
+ if loaded is None:
317
+ return
318
+ data_path, data = loaded
319
+ bad = malformed_conformance_stages(data)
320
+ if not bad:
321
+ return
322
+ # 한 stage 씩 고치고 다시 막히는 왕복을 피하려면 전부 나열해야 한다 —
323
+ # 실패한 런에서 검증기는 stage 2 만 지목했지만 실제로는 2~7 전부였다.
324
+ stages = ", ".join(str(number) for number in bad)
325
+ raise PrepareError(
326
+ f"approved plan data.json has malformed conformanceTests for stage(s) "
327
+ f"{stages}: {data_path}\n"
328
+ " each declaring stage must read "
329
+ "`<task_root>/qa/scripts/stage-<N>.<ext> "
330
+ "(requires=[db|io|http|external,...])` after the "
331
+ "`Conformance tests: stage-<N> — ` prefix "
332
+ "(prompts/profiles/implementation-planning.md), or carry "
333
+ "`Conformance exemption: <reason>` instead.\n"
334
+ " re-run implementation-planning so the declaration is regenerated in "
335
+ "that form, then approve it."
336
+ )
337
+
338
+
299
339
  def _set_data_json_approved_true_if_present(path: Path) -> bool:
300
340
  loaded = _load_final_report_data_if_present(path)
301
341
  if loaded is None:
@@ -474,6 +514,7 @@ def _validate_approved_plan(path: str) -> None:
474
514
  )
475
515
  _reject_blocking_plan_body_gate(p, body, action="approved plan validation")
476
516
  _validate_data_json_approval_consistency(p, markdown_approved=True)
517
+ _validate_approved_plan_conformance(p)
477
518
  # frontmatter approved == true 상태. §1 Clarification Items 의
478
519
  # Blocks=approval 행이 아직 open/answered 면 승인을 무효화한다.
479
520
  scan = scan_approval_gate(p)
@@ -1,6 +1,6 @@
1
1
  """Build a task-type-scoped excerpt of the final-report schema.
2
2
 
3
- The full schema (``schemas/final-report-v1.0.schema.json``) carries the
3
+ The full schema (``schemas/final-report-v2.0.schema.json``) carries the
4
4
  deliverable property blocks for ALL task-types (``errorAnalysis``, the three
5
5
  read-only analysis blocks, ``implementationPlanning``, ``releaseHandoff``,
6
6
  ``implementation``, and ``finalVerification``) plus a
@@ -21,7 +21,7 @@ CONTRACT_RULES = frozenset(
21
21
  )
22
22
 
23
23
  # Mirrors the report schema's requirement id pattern
24
- # (schemas/final-report-v1.0.schema.json: `^R-\d{3,}$`).
24
+ # (schemas/final-report-v2.0.schema.json: `^R-\d{3,}$`).
25
25
  _REQ_ID_RE = re.compile(r"^R-\d{3,}$")
26
26
  _BRIEF_RE = re.compile(r"^brief:\s*(?P<heading>.+?)\s*$")
27
27
  _DERIVED_RE = re.compile(r"^derived:\s*(?P<parent>\S+)\s*[—-]\s*(?P<reason>.+?)\s*$")
@@ -1,21 +1,22 @@
1
1
  """Claude session helpers.
2
2
 
3
- bash session.sh 의 python 구현. claude session id 생성, resume command
4
- 파일 작성.
3
+ bash session.sh 의 python 구현. lead claude 세션 관측, resume command
4
+ 파일 작성. 세션 id 발급기는 워커 dispatch 도 쓰므로
5
+ `dispatch_state.generate_claude_session_id` 에 있다.
5
6
  """
6
7
  from __future__ import annotations
7
8
 
8
9
  import json
9
10
  import os
10
- import uuid
11
11
  from pathlib import Path
12
+ from typing import Collection
12
13
 
13
- from .dispatch_state import DispatchError, mutate_team_state
14
-
15
-
16
- def generate_claude_session_id() -> str:
17
- """UUIDv4 문자열."""
18
- return str(uuid.uuid4())
14
+ from .dispatch_state import (
15
+ DispatchError,
16
+ load_json_object,
17
+ mutate_team_state,
18
+ worker_session_ids,
19
+ )
19
20
 
20
21
 
21
22
  def _claude_projects_dir_for(cwd: Path) -> Path:
@@ -76,7 +77,11 @@ def _session_mentions(jsonl_path: Path, needle: str) -> bool:
76
77
  return False
77
78
 
78
79
 
79
- def resolve_lead_session_id_for_run(project_root: Path, run_dir: Path) -> str:
80
+ def resolve_lead_session_id_for_run(
81
+ project_root: Path,
82
+ run_dir: Path,
83
+ exclude_session_ids: Collection[str] = (),
84
+ ) -> str:
80
85
  """`run_dir` 를 다룬 lead 세션 중 가장 최근 수정된 것의 id, 없으면 ''.
81
86
 
82
87
  프로젝트 디렉토리의 최신 jsonl 을 그대로 집으면(``resolve_inproc_lead_session_id``)
@@ -84,6 +89,12 @@ def resolve_lead_session_id_for_run(project_root: Path, run_dir: Path) -> str:
84
89
  (dev-10172): 남의 세션이 `leadSessionIds` 에 들어가 run 비용이 $80 → $207 로
85
90
  부풀고, 그 세션의 PROGRESS 라인이 conformance 스캔에 섞여 오탐을 냈다.
86
91
  자기 run 의 산출물 경로를 언급한 세션만 후보로 인정해 그 오염을 막는다.
92
+
93
+ `exclude_session_ids` 는 같은 run **안**의 오염을 막는다. cmux 워커는
94
+ `agentName` 을 안 남겨 아래 필터를 통과하고, 워커 세션도 자기 run 의 산출물
95
+ 경로를 언급하므로 needle 에도 걸린다. 워커가 리드보다 늦게 끝나면 mtime
96
+ 정렬에서 먼저 잡혀 워커 세션이 리드로 기록된다 — dispatch 가 발급한 id 를
97
+ 호출자가 넘겨 그 세션들을 후보에서 뺀다.
87
98
  """
88
99
  proj_dir = _claude_projects_dir_for(project_root)
89
100
  try:
@@ -91,7 +102,10 @@ def resolve_lead_session_id_for_run(project_root: Path, run_dir: Path) -> str:
91
102
  except OSError:
92
103
  return ""
93
104
  needle = str(run_dir)
105
+ excluded = set(exclude_session_ids)
94
106
  for path in sorted(candidates, key=lambda p: p.stat().st_mtime, reverse=True):
107
+ if path.stem in excluded:
108
+ continue
95
109
  if _session_has_agent_name(path):
96
110
  continue
97
111
  if _session_mentions(path, needle):
@@ -103,17 +117,36 @@ def record_observed_lead_session(project_root: Path, team_state_path: Path) -> s
103
117
  """이 run 의 live lead 세션을 관측해 team-state 에 append(멱등). 이 run 을
104
118
  다룬 lead 세션을 못 찾거나 중복이면 '' 반환. 재발급으로 갈린 세대를 축1 이
105
119
  여기에 모은다.
120
+
121
+ 후보에서 뺄 워커 세션 id 는 append 대상인 team-state 자체가 들고 있다 —
122
+ dispatch 가 `workerDispatches[].sessionId` 에 적어 둔 값이라, 관측 시점에
123
+ 이 run 이 연 워커 세션의 목록은 그 파일 하나로 완결된다.
106
124
  """
107
- sid = resolve_lead_session_id_for_run(project_root, team_state_path.parent.parent)
125
+ try:
126
+ team_state = load_json_object(team_state_path, "team-state")
127
+ except (DispatchError, OSError):
128
+ team_state = {}
129
+ sid = resolve_lead_session_id_for_run(
130
+ project_root,
131
+ team_state_path.parent.parent,
132
+ exclude_session_ids=worker_session_ids(team_state),
133
+ )
108
134
  if not sid:
109
135
  return ""
110
136
  def add_observed_session(state: dict) -> bool:
111
137
  lead_ids = state.setdefault("leadSessionIds", [])
138
+ observed = state.setdefault("observedTeamNames", [])
139
+ # 두 키가 list 가 아니면 `sid in None` 이 TypeError 를, `str.append` 가
140
+ # AttributeError 를 낸다. 그 예외는 아래 `(DispatchError, OSError)` 도
141
+ # `observe_lead_session` 의 `OSError` 도 통과해 dispatch 를 죽인다 —
142
+ # 관측이 호출자를 깨뜨리지 않는다는 계약을 docstring 이 아니라 여기서
143
+ # 지킨다. 관측을 건너뛰면 이 run 은 관측 이전과 같은 상태로 남는다.
144
+ if not isinstance(lead_ids, list) or not isinstance(observed, list):
145
+ return False
112
146
  if sid in lead_ids:
113
147
  return False
114
148
  lead_ids.append(sid)
115
149
  team = f"session-{sid[:8]}"
116
- observed = state.setdefault("observedTeamNames", [])
117
150
  if team not in observed:
118
151
  observed.append(team)
119
152
  return True
@@ -125,6 +158,30 @@ def record_observed_lead_session(project_root: Path, team_state_path: Path) -> s
125
158
  return sid if changed else ""
126
159
 
127
160
 
161
+ def observe_lead_session(project_root: Path, team_state_path: Path) -> None:
162
+ """단계 경계에서 부르는 부수효과 — 관측 실패를 호출자에게 내지 않는다.
163
+
164
+ 리드 세션은 resume·compaction 으로 세대가 갈리므로 prepare 때 적힌 단 하나의
165
+ id 는 런 구간에 레코드가 없는 죽은 세션을 가리킬 수 있다. 리드가 반드시
166
+ 지나가는 지점마다 관측해 `leadSessionIds` 에 세대를 모은다 — 리드의 협조를
167
+ 요구하지 않는 것이 요점이다.
168
+
169
+ 잡는 범위가 OSError 뿐인 이유: 이 아래 호출들이 각자 자기 실패를 흡수하고,
170
+ 그러고도 밖으로 나오는 것이 후보 정렬의 `p.stat()` 이다 — 스캔 도중 세션
171
+ jsonl 이 사라지면 여기서 OSError 가 난다. team-state 읽기·쓰기
172
+ (`load_json_object` / `mutate_team_state`)와 세션 본문 스캔이 그 흡수
173
+ 지점이고, 비정상 team-state 가 낼 TypeError/AttributeError 는 잡는 대신 아예
174
+ 내지 않는 쪽으로 막는다 — append 대상 키(`leadSessionIds` /
175
+ `observedTeamNames`)는 `add_observed_session` 이, 제외 집합의 출처
176
+ (`workerDispatches`)는 `worker_session_ids` 가 각각 비-list 를 걸러낸다.
177
+ 더 넓게 잡으면 team-state 를 깨뜨리는 진짜 버그까지 조용히 삼킨다.
178
+ """
179
+ try:
180
+ record_observed_lead_session(project_root, team_state_path)
181
+ except OSError:
182
+ return
183
+
184
+
128
185
  def write_claude_resume_command_file(
129
186
  *,
130
187
  resume_command_path: Path,