okstra 0.34.1 → 0.36.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 (101) hide show
  1. package/README.kr.md +26 -16
  2. package/README.md +26 -16
  3. package/docs/kr/architecture.md +59 -45
  4. package/docs/kr/cli.md +61 -18
  5. package/docs/pr-template-usage.md +65 -0
  6. package/docs/project-structure-overview.md +358 -354
  7. package/docs/superpowers/plans/2026-05-12-ticket-id-in-reports.md +1 -1
  8. package/docs/superpowers/plans/2026-05-14-convergence-queue-pruning.md +1 -1
  9. package/docs/superpowers/plans/2026-05-17-dual-format-final-report.md +1 -1
  10. package/docs/superpowers/plans/2026-05-20-final-report-language.md +1501 -0
  11. package/docs/superpowers/plans/2026-05-20-implementation-planning-multi-stage.md +1267 -0
  12. package/docs/superpowers/plans/2026-05-20-okstra-run-prompt-sot-b1.md +1007 -0
  13. package/docs/superpowers/plans/2026-05-20-wizard-messages-json-sot.md +720 -0
  14. package/docs/superpowers/plans/2026-05-20-wizard-prompt-json-sot-a1.md +681 -0
  15. package/docs/superpowers/plans/2026-05-21-improvement-discovery-task-type.md +1691 -0
  16. package/docs/superpowers/specs/2026-05-20-final-report-language-design.md +383 -0
  17. package/docs/superpowers/specs/2026-05-20-implementation-planning-multi-stage-design.md +320 -0
  18. package/docs/superpowers/specs/2026-05-20-okstra-run-prompt-sot-design.md +299 -0
  19. package/docs/superpowers/specs/2026-05-21-improvement-discovery-task-type-design.md +335 -0
  20. package/docs/task-process/README.md +74 -0
  21. package/docs/task-process/common-flow.md +166 -0
  22. package/docs/task-process/error-analysis.md +101 -0
  23. package/docs/task-process/final-verification.md +167 -0
  24. package/docs/task-process/implementation-planning.md +128 -0
  25. package/docs/task-process/implementation.md +149 -0
  26. package/docs/task-process/release-handoff.md +206 -0
  27. package/docs/task-process/requirements-discovery.md +115 -0
  28. package/package.json +1 -1
  29. package/runtime/BUILD.json +2 -2
  30. package/runtime/agents/SKILL.md +12 -2
  31. package/runtime/agents/workers/claude-worker.md +26 -0
  32. package/runtime/agents/workers/codex-worker.md +27 -1
  33. package/runtime/agents/workers/gemini-worker.md +27 -1
  34. package/runtime/agents/workers/report-writer-worker.md +8 -1
  35. package/runtime/bin/okstra-central.sh +6 -6
  36. package/runtime/bin/okstra-codex-exec.sh +49 -28
  37. package/runtime/bin/okstra-gemini-exec.sh +39 -21
  38. package/runtime/bin/okstra-render-final-report.py +13 -2
  39. package/runtime/bin/okstra-wrapper-status.py +155 -0
  40. package/runtime/bin/okstra.sh +2 -2
  41. package/runtime/prompts/profiles/_common-contract.md +11 -6
  42. package/runtime/prompts/profiles/error-analysis.md +3 -7
  43. package/runtime/prompts/profiles/implementation-planning.md +22 -21
  44. package/runtime/prompts/profiles/implementation.md +28 -11
  45. package/runtime/prompts/profiles/improvement-discovery.md +42 -0
  46. package/runtime/prompts/profiles/kr/_common-contract.md +92 -0
  47. package/runtime/prompts/profiles/kr/error-analysis.md +36 -0
  48. package/runtime/prompts/profiles/kr/final-verification.md +48 -0
  49. package/runtime/prompts/profiles/kr/implementation-planning.md +90 -0
  50. package/runtime/prompts/profiles/kr/implementation.md +144 -0
  51. package/runtime/prompts/profiles/kr/improvement-discovery.md +42 -0
  52. package/runtime/prompts/profiles/kr/release-handoff.md +104 -0
  53. package/runtime/prompts/profiles/kr/requirements-discovery.md +42 -0
  54. package/runtime/prompts/profiles/release-handoff.md +1 -1
  55. package/runtime/prompts/profiles/requirements-discovery.md +8 -12
  56. package/runtime/prompts/wizard/prompts.ko.json +230 -0
  57. package/runtime/python/lib/okstra/cli.sh +2 -49
  58. package/runtime/python/lib/okstra/globals.sh +21 -21
  59. package/runtime/python/lib/okstra/interactive.sh +7 -7
  60. package/runtime/python/okstra_ctl/clarification_items.py +3 -9
  61. package/runtime/python/okstra_ctl/consumers.py +53 -0
  62. package/runtime/python/okstra_ctl/final_report_schema.py +0 -7
  63. package/runtime/python/okstra_ctl/i18n.py +73 -0
  64. package/runtime/python/okstra_ctl/improvement_lenses.py +44 -0
  65. package/runtime/python/okstra_ctl/index.py +1 -1
  66. package/runtime/python/okstra_ctl/paths.py +23 -20
  67. package/runtime/python/okstra_ctl/render.py +147 -202
  68. package/runtime/python/okstra_ctl/render_final_report.py +53 -10
  69. package/runtime/python/okstra_ctl/run.py +292 -107
  70. package/runtime/python/okstra_ctl/run_context.py +22 -0
  71. package/runtime/python/okstra_ctl/seeding.py +186 -0
  72. package/runtime/python/okstra_ctl/wizard.py +348 -127
  73. package/runtime/python/okstra_ctl/workflow.py +21 -2
  74. package/runtime/python/okstra_ctl/worktree.py +54 -1
  75. package/runtime/python/okstra_project/resolver.py +4 -3
  76. package/runtime/python/okstra_token_usage/report.py +2 -2
  77. package/runtime/schemas/final-report-v1.0.schema.json +22 -16
  78. package/runtime/skills/okstra-brief/SKILL.md +124 -31
  79. package/runtime/skills/okstra-convergence/SKILL.md +2 -3
  80. package/runtime/skills/okstra-report-writer/SKILL.md +35 -15
  81. package/runtime/skills/okstra-run/SKILL.md +5 -4
  82. package/runtime/skills/okstra-schedule/SKILL.md +4 -4
  83. package/runtime/skills/okstra-setup/SKILL.md +27 -0
  84. package/runtime/skills/okstra-team-contract/SKILL.md +1 -1
  85. package/runtime/templates/okstra.CLAUDE.md +104 -0
  86. package/runtime/templates/reports/final-report.template.md +93 -98
  87. package/runtime/templates/reports/i18n/en.json +135 -0
  88. package/runtime/templates/reports/i18n/ko.json +135 -0
  89. package/runtime/templates/reports/implementation-planning-input.template.md +18 -0
  90. package/runtime/templates/reports/improvement-discovery-input.template.md +78 -0
  91. package/runtime/templates/reports/task-brief.template.md +2 -2
  92. package/runtime/validators/lib/fixtures.sh +30 -0
  93. package/runtime/validators/lib/runners.sh +1 -1
  94. package/runtime/validators/validate-implementation-plan-stages.py +211 -0
  95. package/runtime/validators/validate-run.py +121 -26
  96. package/runtime/validators/validate-workflow.sh +2 -2
  97. package/runtime/validators/validate_improvement_report.py +275 -0
  98. package/src/config.mjs +18 -0
  99. package/src/install.mjs +41 -14
  100. package/src/setup.mjs +133 -1
  101. package/src/uninstall.mjs +21 -1
@@ -54,6 +54,7 @@ def write_json(path: Path, payload: dict) -> None:
54
54
  def default_next_phase(task_type: str) -> str:
55
55
  mapping = {
56
56
  "requirements-discovery": "pending-routing-decision",
57
+ "improvement-discovery": "pending-routing-decision",
57
58
  "error-analysis": "implementation-planning",
58
59
  "implementation-planning": "implementation",
59
60
  "implementation": "final-verification",
@@ -144,10 +145,11 @@ def update_workflow_metadata(
144
145
  awaiting_approval = workflow.get("awaitingApproval")
145
146
  if not isinstance(awaiting_approval, bool):
146
147
  awaiting_approval = False
147
- # 승인 게이트(`User Approval Request`)는 implementation 진입 직전에 한 번만 의미를 가진다.
148
+ # 승인 게이트(`frontmatter approved`)는 implementation 진입 직전에 한 번만 의미를 가진다.
148
149
  # implementation run 이 검증을 통과했다는 것은 `_validate_approved_plan` 이 이미 사용자
149
- # 마커를 소비했다는 뜻이므로, 이 시점에 awaitingApproval 플래그를 명시적으로 내려
150
- # 다음 phase 의 status 뷰에서 stale 상태로 남지 않게 한다.
150
+ # 승인 플래그(frontmatter `approved: true`)를 소비했다는 뜻이므로, 이 시점에
151
+ # awaitingApproval 플래그를 명시적으로 내려 다음 phase 의 status 뷰에서 stale 상태로
152
+ # 남지 않게 한다.
151
153
  if validation_status == "passed" and current_phase == "implementation":
152
154
  awaiting_approval = False
153
155
 
@@ -488,7 +490,7 @@ TOKEN_PLACEHOLDERS = (
488
490
  # heading (or end-of-file). Matched non-greedily so the body of the next
489
491
  # section never bleeds in.
490
492
  _TOKEN_USAGE_SECTION_RE = re.compile(
491
- r"^##[ \t]+Token Usage Summary[ \t]*$\n(?P<body>.*?)(?=^##[ \t]|\Z)",
493
+ r"^##[ \t]+(?:Token Usage Summary|토큰 사용량 요약)[ \t]*$\n(?P<body>.*?)(?=^##[ \t]|\Z)",
492
494
  re.DOTALL | re.MULTILINE,
493
495
  )
494
496
 
@@ -548,7 +550,8 @@ def _scan_token_usage_summary(content: str, failures: list[str]) -> None:
548
550
  # surfaced elsewhere by the placeholder check (which would also
549
551
  # not fire — so we add a dedicated failure here).
550
552
  failures.append(
551
- "final report is missing the `## Token Usage Summary` section — "
553
+ "final report is missing the `## Token Usage Summary` "
554
+ "(or `## 토큰 사용량 요약`) section — "
552
555
  "the template renders it unconditionally and Phase 7 substitution "
553
556
  "depends on it being present."
554
557
  )
@@ -632,17 +635,21 @@ _EMPTY_CARRY_IN_SOURCE_RE = re.compile(
632
635
  # (e.g. this file itself, or skill documentation that mentions the
633
636
  # deprecated names).
634
637
  _DEPRECATED_FINAL_REPORT_PATTERNS: tuple[tuple[re.Pattern, str], ...] = (
638
+ (
639
+ re.compile(r"^##[ \t]+User Approval Request\b", re.MULTILINE),
640
+ "deprecated `## User Approval Request` block — approval gate moved to "
641
+ "the YAML frontmatter `approved: true|false` field. Delete the body section.",
642
+ ),
635
643
  (
636
644
  re.compile(r"^###[ \t]+4\.5\.8[ \t]+User Approval Request\b", re.MULTILINE),
637
- "deprecated `### 4.5.8 User Approval Request` stub — the top-of-report "
638
- "`## User Approval Request (사용자 승인 게이트)` block is the only one. "
639
- "Delete the §4.5.8 heading + body.",
645
+ "deprecated `### 4.5.8 User Approval Request` stub — approval gate moved "
646
+ "to the YAML frontmatter `approved: true|false` field. Delete the §4.5.8 heading + body.",
640
647
  ),
641
648
  (
642
649
  re.compile(r"^###[ \t]+4\.5\.9[ \t]+Open Questions\b", re.MULTILINE),
643
650
  "deprecated `### 4.5.9 Open Questions` block — promote each row into "
644
651
  "`## 5. Clarification Items` with `Kind=decision` (and `Blocks=approval` "
645
- "if it gated the User Approval Request).",
652
+ "if it gates the frontmatter approval flag).",
646
653
  ),
647
654
  (
648
655
  re.compile(
@@ -847,7 +854,6 @@ PLANNING_REQUIRED_SECTIONS = (
847
854
  "Dependency",
848
855
  "Validation Checklist",
849
856
  "Rollback",
850
- "User Approval Request",
851
857
  "Plan Body Verification",
852
858
  )
853
859
 
@@ -933,15 +939,14 @@ _GATE_RESULT_RE = re.compile(
933
939
  re.IGNORECASE,
934
940
  )
935
941
 
936
- # Approval marker linethe bullet that toggles to grant approval. Both
937
- # unchecked (`- [ ] Approved`) and checked (`- [x] Approved`) forms count
938
- # as "checkbox present" for this gate. Line-anchored to avoid false
939
- # positives from inline-code prose examples elsewhere in the template
940
- # (mirrors `APPROVED_PLAN_PATTERN` in scripts/okstra_ctl/run.py:79).
941
- _APPROVAL_CHECKBOX_RE = re.compile(
942
- r"^[ \t]*(?:[-*+][ \t]+)?`?\[[ xX]\][ \t]*Approved`?[ \t]*$",
943
- re.MULTILINE,
942
+ # Frontmatter approval flag`approved: true|false` line inside the
943
+ # leading `---` YAML block. Mirrors `APPROVED_FRONTMATTER_PATTERN` in
944
+ # scripts/okstra_ctl/run.py.
945
+ _APPROVED_FRONTMATTER_RE = re.compile(
946
+ r"^approved:[ \t]+(true|false)[ \t]*$",
947
+ re.IGNORECASE | re.MULTILINE,
944
948
  )
949
+ _FRONTMATTER_BLOCK_RE = re.compile(r"\A---\n(.*?)\n---\n", re.DOTALL)
945
950
 
946
951
 
947
952
  def _extract_final_verdict_token(content: str) -> str | None:
@@ -1182,20 +1187,101 @@ def validate_phase_boundary(
1182
1187
  f"{', '.join(PLAN_VERIFY_GATE_VALUES)}."
1183
1188
  )
1184
1189
  return
1185
- checkbox_present = _APPROVAL_CHECKBOX_RE.search(content) is not None
1186
- if gate_value in ("passed", "passed-with-dissent") and not checkbox_present:
1190
+ fm_block = _FRONTMATTER_BLOCK_RE.match(content)
1191
+ fm_match = (
1192
+ _APPROVED_FRONTMATTER_RE.search(fm_block.group(1)) if fm_block else None
1193
+ )
1194
+ if gate_value in ("passed", "passed-with-dissent") and fm_match is None:
1187
1195
  failures.append(
1188
1196
  "implementation-planning report Gate result is "
1189
- f"`{gate_value}` but the top-level `User Approval Request` "
1190
- "checkbox line (`- [ ] Approved` / `- [x] Approved`) is missing."
1197
+ f"`{gate_value}` but the frontmatter `approved:` field is missing — "
1198
+ "render `approved: false` so the user (or `--approve`) can flip it."
1191
1199
  )
1192
- if gate_value in ("blocked-by-disagreement", "aborted-non-result") and checkbox_present:
1200
+ if (
1201
+ gate_value in ("blocked-by-disagreement", "aborted-non-result")
1202
+ and fm_match is not None
1203
+ and fm_match.group(1).lower() == "true"
1204
+ ):
1193
1205
  failures.append(
1194
1206
  "implementation-planning report Gate result is "
1195
- f"`{gate_value}` but a top-level `User Approval Request` "
1196
- "checkbox line is present gate must NOT render the checkbox "
1197
- "for this gate result."
1207
+ f"`{gate_value}` but the frontmatter has `approved: true` — gate "
1208
+ "must NOT publish a pre-approved plan when verification did not pass."
1209
+ )
1210
+
1211
+
1212
+ def _parse_brief_frontmatter(brief_path: Path) -> dict:
1213
+ """Parse YAML frontmatter from a brief file into a flat dict.
1214
+
1215
+ Handles the scalar and inline-flow-sequence shapes used by okstra briefs:
1216
+ key: scalar_value
1217
+ key: [item1, item2]
1218
+
1219
+ Returns {} when the file is absent, has no ``---`` delimiters, or the
1220
+ frontmatter block is empty. Does not raise.
1221
+ """
1222
+ if not brief_path.is_file():
1223
+ return {}
1224
+ try:
1225
+ text = brief_path.read_text(encoding="utf-8")
1226
+ except OSError:
1227
+ return {}
1228
+
1229
+ fm_match = _FRONTMATTER_BLOCK_RE.match(text)
1230
+ if fm_match is None:
1231
+ return {}
1232
+
1233
+ result: dict = {}
1234
+ for line in fm_match.group(1).splitlines():
1235
+ if ":" not in line:
1236
+ continue
1237
+ key, _, raw_val = line.partition(":")
1238
+ key = key.strip()
1239
+ raw_val = raw_val.strip()
1240
+ if not key:
1241
+ continue
1242
+ # Inline flow sequence: [a, b, c]
1243
+ if raw_val.startswith("[") and raw_val.endswith("]"):
1244
+ inner = raw_val[1:-1]
1245
+ items = [s.strip() for s in inner.split(",") if s.strip()]
1246
+ result[key] = items
1247
+ else:
1248
+ result[key] = raw_val
1249
+ return result
1250
+
1251
+
1252
+ def _validate_improvement_discovery(
1253
+ report_path: Path,
1254
+ run_dir: Path,
1255
+ brief_path: Path,
1256
+ failures: list[str],
1257
+ ) -> None:
1258
+ """Call validate_improvement_report and fold errors into failures.
1259
+
1260
+ Errors from the phase-specific validator are prefixed with
1261
+ ``improvement-discovery: `` to match the style used by sibling validators
1262
+ (e.g. ``report-views: <line>``).
1263
+ """
1264
+ _VALIDATORS_DIR_LOCAL = Path(__file__).resolve().parent
1265
+ if str(_VALIDATORS_DIR_LOCAL) not in sys.path:
1266
+ sys.path.insert(0, str(_VALIDATORS_DIR_LOCAL))
1267
+
1268
+ try:
1269
+ from validate_improvement_report import validate_improvement_report # noqa: E402
1270
+ except ImportError as exc:
1271
+ failures.append(
1272
+ f"improvement-discovery: validate_improvement_report import failed — {exc}"
1198
1273
  )
1274
+ return
1275
+
1276
+ brief_frontmatter = _parse_brief_frontmatter(brief_path)
1277
+ result = validate_improvement_report(
1278
+ report_path=report_path,
1279
+ run_dir=run_dir,
1280
+ brief_frontmatter=brief_frontmatter,
1281
+ )
1282
+ if not result.ok:
1283
+ for err in result.errors:
1284
+ failures.append(f"improvement-discovery: {err}")
1199
1285
 
1200
1286
 
1201
1287
  def _refresh_task_catalog(project_root: Path, task_manifest: dict) -> tuple[bool, str]:
@@ -1535,6 +1621,15 @@ def main() -> int:
1535
1621
  validate_phase_boundary(task_type, report_path, failures)
1536
1622
  if task_type:
1537
1623
  validate_worker_results_audit(report_path, task_type, failures)
1624
+ if task_type == "improvement-discovery":
1625
+ brief_relative = str(task_manifest.get("taskBriefPath") or "").strip()
1626
+ brief_path = (
1627
+ (project_root / brief_relative).resolve()
1628
+ if brief_relative
1629
+ else project_root / "__no-brief__" # absent path → _parse_brief_frontmatter returns {}
1630
+ )
1631
+ run_dir = report_path.parent.parent
1632
+ _validate_improvement_discovery(report_path, run_dir, brief_path, failures)
1538
1633
  validate_report_views(report_path, failures)
1539
1634
 
1540
1635
  validation_status = "passed" if not failures else "failed"
@@ -15,7 +15,7 @@ PROJECT_ID="okstra-validation"
15
15
  PROJECT_ROOT="${OKSTRA_VALIDATION_PROJECT_ROOT:-/tmp/okstra-validate.workflow}"
16
16
  WORKSPACE_APP_PATH="$PROJECT_ROOT"
17
17
  OKSTRA_SCRIPT="$WORKSPACE_ROOT/scripts/okstra.sh"
18
- RUN_VALIDATOR_SCRIPT="$WORKSPACE_ROOT/validators/validate-run.py"
18
+ RUN_VALIDATOR_PATH="$WORKSPACE_ROOT/validators/validate-run.py"
19
19
  SOURCE_ASSET_ROOT="$WORKSPACE_ROOT/agents"
20
20
  TASK_TYPE="final-verification"
21
21
  PRIMARY_TASK_GROUP="validation"
@@ -47,7 +47,7 @@ source "$SCRIPT_DIR/lib/summary.sh"
47
47
  trap 'on_error "$LINENO"' ERR
48
48
 
49
49
  require_file "$OKSTRA_SCRIPT"
50
- require_file "$RUN_VALIDATOR_SCRIPT"
50
+ require_file "$RUN_VALIDATOR_PATH"
51
51
 
52
52
  validate_project_root_safety
53
53
 
@@ -0,0 +1,275 @@
1
+ """Validator for final-report.md produced by the improvement-discovery phase.
2
+
3
+ Enforces the 11-item contract in
4
+ docs/superpowers/specs/2026-05-21-improvement-discovery-task-type-design.md §6.5.
5
+
6
+ Called by validators/validate-run.py when task_type == "improvement-discovery".
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import re
11
+ import sys
12
+ from dataclasses import dataclass, field
13
+ from pathlib import Path
14
+
15
+ # scripts/ is not a package; insert it so okstra_ctl is importable directly.
16
+ _SCRIPTS_DIR = Path(__file__).resolve().parent.parent / "scripts"
17
+ if str(_SCRIPTS_DIR) not in sys.path:
18
+ sys.path.insert(0, str(_SCRIPTS_DIR))
19
+
20
+ from okstra_ctl.improvement_lenses import (
21
+ LENSES,
22
+ DEFAULT_CANDIDATE_CAP,
23
+ ABSOLUTE_CANDIDATE_CAP,
24
+ SOURCE_WORKERS,
25
+ )
26
+
27
+
28
+ _VERDICT_TOKENS = ("candidates-ready", "no-candidates", "blocked")
29
+ _NEXT_PHASES = ("requirements-discovery", "implementation-planning", "error-analysis")
30
+ _CAND_ID_RE = re.compile(r"^I-\d{3}$")
31
+ _SOURCE_WORKER_RE = re.compile(r"^([a-z-]+):([A-Za-z0-9._-]+)$")
32
+ _CONSENSUS_VALUES = ("full", "partial", "contested", "worker-unique")
33
+
34
+
35
+ @dataclass
36
+ class ValidationResult:
37
+ ok: bool
38
+ errors: list[str] = field(default_factory=list)
39
+
40
+
41
+ def _read_section_table(body: str, heading: str) -> list[list[str]]:
42
+ """Return rows of the markdown pipe-table directly under ``heading``.
43
+ Each row is a list of trimmed cell values. Returns [] if heading absent
44
+ or no table follows.
45
+ """
46
+ pattern = rf"(?m)^##\s+{re.escape(heading)}\b.*?\n(.*?)(?=^##\s|\Z)"
47
+ m = re.search(pattern, body, flags=re.S)
48
+ if not m:
49
+ return []
50
+ section = m.group(1)
51
+ rows: list[list[str]] = []
52
+ for line in section.splitlines():
53
+ s = line.strip()
54
+ if not s.startswith("|") or not s.endswith("|"):
55
+ continue
56
+ cells = [c.strip() for c in s.strip("|").split("|")]
57
+ if all(set(c) <= set("-: ") for c in cells):
58
+ continue
59
+ rows.append(cells)
60
+ return rows
61
+
62
+
63
+ def _candidate_cap(brief_frontmatter: dict) -> int:
64
+ raw = brief_frontmatter.get("candidate-cap")
65
+ if raw is None:
66
+ return DEFAULT_CANDIDATE_CAP
67
+ try:
68
+ return int(raw)
69
+ except (TypeError, ValueError):
70
+ return DEFAULT_CANDIDATE_CAP
71
+
72
+
73
+ def _scope_subset(
74
+ candidate_scope_csv: str,
75
+ scan_scope: list[str],
76
+ out_of_scope: list[str],
77
+ ) -> tuple[bool, str]:
78
+ paths = [p.strip() for p in candidate_scope_csv.split(",") if p.strip()]
79
+ if not paths:
80
+ return False, "empty Scope"
81
+ for p in paths:
82
+ if any(p == s or p.startswith(s.rstrip("/") + "/") for s in scan_scope):
83
+ for o in out_of_scope:
84
+ if p == o or p.startswith(o.rstrip("/") + "/"):
85
+ return False, f"Scope '{p}' is inside out-of-scope '{o}'"
86
+ continue
87
+ return False, f"Scope '{p}' is outside brief scan-scope {scan_scope}"
88
+ return True, ""
89
+
90
+
91
+ def _check_grilling_log(run_dir: Path, errors: list[str]) -> None:
92
+ """Item 10 — phase-1.5-grilling.md must exist with resolved blocks."""
93
+ grilling = run_dir / "state" / "phase-1.5-grilling.md"
94
+ if not grilling.exists():
95
+ errors.append("missing phase-1.5-grilling.md log at runs/.../state/")
96
+ return
97
+ gtext = grilling.read_text(encoding="utf-8")
98
+ if "Resolved scope" not in gtext:
99
+ errors.append("phase-1.5-grilling.md missing 'Resolved scope' block")
100
+ if "Resolved lenses" not in gtext:
101
+ errors.append("phase-1.5-grilling.md missing 'Resolved lenses' block")
102
+
103
+
104
+ def _check_candidates_cap(
105
+ data_row_count: int, brief_frontmatter: dict, errors: list[str]
106
+ ) -> int | None:
107
+ """Item 5 — validate candidate-cap range and row count vs cap.
108
+
109
+ Returns the validated cap, or None when the cap itself is out of range
110
+ (in that case the caller must NOT also flag row-count-exceeds-cap).
111
+ """
112
+ cap = _candidate_cap(brief_frontmatter)
113
+ if cap < 1 or cap > ABSOLUTE_CANDIDATE_CAP:
114
+ errors.append(
115
+ f"brief candidate-cap {cap} out of allowed range 1..{ABSOLUTE_CANDIDATE_CAP}"
116
+ )
117
+ return None
118
+ if data_row_count > cap:
119
+ errors.append(f"row count {data_row_count} exceeds candidate-cap {cap}")
120
+ return cap
121
+
122
+
123
+ def _check_row_sources_and_consensus(
124
+ idx: int,
125
+ source_workers: str,
126
+ consensus: str,
127
+ errors: list[str],
128
+ ) -> None:
129
+ """Item 6 + Consensus enum + single-source → worker-unique invariant."""
130
+ workers_in_row: list[str] = []
131
+ for token in source_workers.split(","):
132
+ token = token.strip()
133
+ if not token:
134
+ continue
135
+ m = _SOURCE_WORKER_RE.match(token)
136
+ if not m:
137
+ errors.append(f"row {idx}: Source workers token '{token}' must match <worker>:<id>")
138
+ continue
139
+ worker, _item = m.group(1), m.group(2)
140
+ if worker not in SOURCE_WORKERS:
141
+ errors.append(
142
+ f"row {idx}: Source workers '{worker}' is not in {SOURCE_WORKERS} (report-writer excluded)"
143
+ )
144
+ workers_in_row.append(worker)
145
+ if not workers_in_row:
146
+ errors.append(f"row {idx}: Source workers cell is empty")
147
+
148
+ if consensus not in _CONSENSUS_VALUES:
149
+ errors.append(f"row {idx}: Consensus '{consensus}' must be one of {_CONSENSUS_VALUES}")
150
+ if len(set(workers_in_row)) == 1 and consensus != "worker-unique":
151
+ errors.append(f"row {idx}: single-source-worker entries must use Consensus=worker-unique")
152
+
153
+
154
+ def _check_candidate_row(
155
+ idx: int,
156
+ row: list[str],
157
+ scan_scope: list[str],
158
+ out_of_scope: list[str],
159
+ seen_ids: set[str],
160
+ errors: list[str],
161
+ ) -> None:
162
+ """Items 2, 3, 4, 6, 7 — validate a single candidate row."""
163
+ if len(row) != 10:
164
+ errors.append(f"row {idx} has {len(row)} columns, expected 10")
165
+ return
166
+ cand_id, lens_cell, _title, scope, _sev, _eff, consensus, source_workers, next_phase, _evidence = row
167
+
168
+ if not _CAND_ID_RE.match(cand_id):
169
+ errors.append(f"row {idx}: Cand ID '{cand_id}' must match I-NNN")
170
+ return
171
+ if cand_id in seen_ids:
172
+ errors.append(f"row {idx}: duplicate Cand ID '{cand_id}'")
173
+ seen_ids.add(cand_id)
174
+
175
+ lenses_in_row = [ln.strip() for ln in lens_cell.split(",") if ln.strip()]
176
+ for ln in lenses_in_row:
177
+ if ln not in LENSES:
178
+ errors.append(f"row {idx}: Lens '{ln}' is not in whitelist {LENSES}")
179
+ if not (1 <= len(lenses_in_row) <= 2):
180
+ errors.append(f"row {idx}: Lens cell must contain 1 or 2 values, got {len(lenses_in_row)}")
181
+
182
+ ok, reason = _scope_subset(scope, scan_scope, out_of_scope)
183
+ if not ok:
184
+ errors.append(f"row {idx}: {reason}")
185
+
186
+ _check_row_sources_and_consensus(idx, source_workers, consensus, errors)
187
+
188
+ if next_phase not in _NEXT_PHASES:
189
+ errors.append(f"row {idx}: Recommended next-phase '{next_phase}' must be one of {_NEXT_PHASES}")
190
+
191
+
192
+ def _check_candidate_rows(
193
+ data_rows: list[list[str]],
194
+ scan_scope: list[str],
195
+ out_of_scope: list[str],
196
+ errors: list[str],
197
+ ) -> None:
198
+ """Items 2..7 — iterate over all data rows, accumulating seen IDs."""
199
+ seen_ids: set[str] = set()
200
+ for idx, row in enumerate(data_rows, start=1):
201
+ _check_candidate_row(idx, row, scan_scope, out_of_scope, seen_ids, errors)
202
+
203
+
204
+ def _check_candidates_table(
205
+ body: str, brief_frontmatter: dict, errors: list[str]
206
+ ) -> None:
207
+ """Items 1–7 coordinator — parse table, check header, delegate cap and rows."""
208
+ rows = _read_section_table(body, "4.9 Improvement Candidates")
209
+ if not rows:
210
+ errors.append("missing or empty `## 4.9 Improvement Candidates` table")
211
+ return
212
+ header, *data = rows
213
+ expected_columns = [
214
+ "Cand ID", "Lens", "Title", "Scope", "Severity", "Effort",
215
+ "Consensus", "Source workers", "Recommended next-phase", "Evidence",
216
+ ]
217
+ if header != expected_columns:
218
+ errors.append(
219
+ f"`## 4.9 Improvement Candidates` header must be {expected_columns}, "
220
+ f"got {header}"
221
+ )
222
+ _check_candidates_cap(len(data), brief_frontmatter, errors)
223
+ scan_scope = brief_frontmatter.get("scan-scope") or []
224
+ out_of_scope = brief_frontmatter.get("out-of-scope") or []
225
+ _check_candidate_rows(data, scan_scope, out_of_scope, errors)
226
+
227
+
228
+ def _check_final_verdict(
229
+ body: str, errors: list[str]
230
+ ) -> list[list[str]]:
231
+ """Item 8 — ## 2. Final Verdict verdict token is in enum.
232
+
233
+ Returns the parsed final_verdict_rows (empty list when absent).
234
+ """
235
+ final_verdict_rows = _read_section_table(body, "2. Final Verdict")
236
+ if not final_verdict_rows:
237
+ errors.append("missing `## 2. Final Verdict` block")
238
+ return []
239
+ token_cell = final_verdict_rows[-1][0] if final_verdict_rows[-1] else ""
240
+ if token_cell not in _VERDICT_TOKENS:
241
+ errors.append(
242
+ f"`## 2. Final Verdict` Verdict Token '{token_cell}' must be one of {_VERDICT_TOKENS}"
243
+ )
244
+ return final_verdict_rows
245
+
246
+
247
+ def _check_verdict_card(
248
+ body: str, final_verdict_rows: list[list[str]], errors: list[str]
249
+ ) -> None:
250
+ """Item 9 — Verdict Card must be present and byte-match Final Verdict row."""
251
+ verdict_card_rows = _read_section_table(body, "Verdict Card")
252
+ if not verdict_card_rows:
253
+ errors.append("missing `## Verdict Card` block")
254
+ return
255
+ if final_verdict_rows and verdict_card_rows[-1] != final_verdict_rows[-1]:
256
+ errors.append("Verdict Card row must byte-match `## 2. Final Verdict` row")
257
+
258
+
259
+ def validate_improvement_report(
260
+ report_path: Path, run_dir: Path, brief_frontmatter: dict
261
+ ) -> ValidationResult:
262
+ errors: list[str] = []
263
+
264
+ if not report_path.exists():
265
+ errors.append(f"report file not found: {report_path}")
266
+ return ValidationResult(ok=False, errors=errors)
267
+
268
+ body = report_path.read_text(encoding="utf-8")
269
+
270
+ _check_grilling_log(run_dir, errors)
271
+ _check_candidates_table(body, brief_frontmatter, errors)
272
+ final_verdict_rows = _check_final_verdict(body, errors)
273
+ _check_verdict_card(body, final_verdict_rows, errors)
274
+
275
+ return ValidationResult(ok=not errors, errors=errors)
package/src/config.mjs CHANGED
@@ -16,6 +16,11 @@ Supported keys (CLI alias -> JSON field):
16
16
  Path may be absolute, ~/-prefixed, or (project scope
17
17
  only) relative to <project-root>.
18
18
 
19
+ report-language -> reportLanguage
20
+ Final-report language. One of: en, ko, auto.
21
+ 'auto' is resolved by the report-writer lead from
22
+ the task brief at run time.
23
+
19
24
  Usage:
20
25
  okstra config get <key> [--scope project|global|all]
21
26
  Print the value at the requested scope.
@@ -63,6 +68,19 @@ const KEYS = {
63
68
  return null;
64
69
  },
65
70
  },
71
+ "report-language": {
72
+ jsonField: "reportLanguage",
73
+ scopes: ["project", "global"],
74
+ validate(value) {
75
+ const allowed = new Set(["en", "ko", "auto"]);
76
+ if (typeof value !== "string" || !allowed.has(value)) {
77
+ return (
78
+ `value must be one of: en, ko, auto (got ${JSON.stringify(value)})`
79
+ );
80
+ }
81
+ return null;
82
+ },
83
+ },
66
84
  };
67
85
 
68
86
  function parseArgs(args) {