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.
- package/README.kr.md +26 -16
- package/README.md +26 -16
- package/docs/kr/architecture.md +59 -45
- package/docs/kr/cli.md +61 -18
- package/docs/pr-template-usage.md +65 -0
- package/docs/project-structure-overview.md +358 -354
- package/docs/superpowers/plans/2026-05-12-ticket-id-in-reports.md +1 -1
- package/docs/superpowers/plans/2026-05-14-convergence-queue-pruning.md +1 -1
- package/docs/superpowers/plans/2026-05-17-dual-format-final-report.md +1 -1
- package/docs/superpowers/plans/2026-05-20-final-report-language.md +1501 -0
- package/docs/superpowers/plans/2026-05-20-implementation-planning-multi-stage.md +1267 -0
- package/docs/superpowers/plans/2026-05-20-okstra-run-prompt-sot-b1.md +1007 -0
- package/docs/superpowers/plans/2026-05-20-wizard-messages-json-sot.md +720 -0
- package/docs/superpowers/plans/2026-05-20-wizard-prompt-json-sot-a1.md +681 -0
- package/docs/superpowers/plans/2026-05-21-improvement-discovery-task-type.md +1691 -0
- package/docs/superpowers/specs/2026-05-20-final-report-language-design.md +383 -0
- package/docs/superpowers/specs/2026-05-20-implementation-planning-multi-stage-design.md +320 -0
- package/docs/superpowers/specs/2026-05-20-okstra-run-prompt-sot-design.md +299 -0
- package/docs/superpowers/specs/2026-05-21-improvement-discovery-task-type-design.md +335 -0
- package/docs/task-process/README.md +74 -0
- package/docs/task-process/common-flow.md +166 -0
- package/docs/task-process/error-analysis.md +101 -0
- package/docs/task-process/final-verification.md +167 -0
- package/docs/task-process/implementation-planning.md +128 -0
- package/docs/task-process/implementation.md +149 -0
- package/docs/task-process/release-handoff.md +206 -0
- package/docs/task-process/requirements-discovery.md +115 -0
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/agents/SKILL.md +12 -2
- package/runtime/agents/workers/claude-worker.md +26 -0
- package/runtime/agents/workers/codex-worker.md +27 -1
- package/runtime/agents/workers/gemini-worker.md +27 -1
- package/runtime/agents/workers/report-writer-worker.md +8 -1
- package/runtime/bin/okstra-central.sh +6 -6
- package/runtime/bin/okstra-codex-exec.sh +49 -28
- package/runtime/bin/okstra-gemini-exec.sh +39 -21
- package/runtime/bin/okstra-render-final-report.py +13 -2
- package/runtime/bin/okstra-wrapper-status.py +155 -0
- package/runtime/bin/okstra.sh +2 -2
- package/runtime/prompts/profiles/_common-contract.md +11 -6
- package/runtime/prompts/profiles/error-analysis.md +3 -7
- package/runtime/prompts/profiles/implementation-planning.md +22 -21
- package/runtime/prompts/profiles/implementation.md +28 -11
- package/runtime/prompts/profiles/improvement-discovery.md +42 -0
- package/runtime/prompts/profiles/kr/_common-contract.md +92 -0
- package/runtime/prompts/profiles/kr/error-analysis.md +36 -0
- package/runtime/prompts/profiles/kr/final-verification.md +48 -0
- package/runtime/prompts/profiles/kr/implementation-planning.md +90 -0
- package/runtime/prompts/profiles/kr/implementation.md +144 -0
- package/runtime/prompts/profiles/kr/improvement-discovery.md +42 -0
- package/runtime/prompts/profiles/kr/release-handoff.md +104 -0
- package/runtime/prompts/profiles/kr/requirements-discovery.md +42 -0
- package/runtime/prompts/profiles/release-handoff.md +1 -1
- package/runtime/prompts/profiles/requirements-discovery.md +8 -12
- package/runtime/prompts/wizard/prompts.ko.json +230 -0
- package/runtime/python/lib/okstra/cli.sh +2 -49
- package/runtime/python/lib/okstra/globals.sh +21 -21
- package/runtime/python/lib/okstra/interactive.sh +7 -7
- package/runtime/python/okstra_ctl/clarification_items.py +3 -9
- package/runtime/python/okstra_ctl/consumers.py +53 -0
- package/runtime/python/okstra_ctl/final_report_schema.py +0 -7
- package/runtime/python/okstra_ctl/i18n.py +73 -0
- package/runtime/python/okstra_ctl/improvement_lenses.py +44 -0
- package/runtime/python/okstra_ctl/index.py +1 -1
- package/runtime/python/okstra_ctl/paths.py +23 -20
- package/runtime/python/okstra_ctl/render.py +147 -202
- package/runtime/python/okstra_ctl/render_final_report.py +53 -10
- package/runtime/python/okstra_ctl/run.py +292 -107
- package/runtime/python/okstra_ctl/run_context.py +22 -0
- package/runtime/python/okstra_ctl/seeding.py +186 -0
- package/runtime/python/okstra_ctl/wizard.py +348 -127
- package/runtime/python/okstra_ctl/workflow.py +21 -2
- package/runtime/python/okstra_ctl/worktree.py +54 -1
- package/runtime/python/okstra_project/resolver.py +4 -3
- package/runtime/python/okstra_token_usage/report.py +2 -2
- package/runtime/schemas/final-report-v1.0.schema.json +22 -16
- package/runtime/skills/okstra-brief/SKILL.md +124 -31
- package/runtime/skills/okstra-convergence/SKILL.md +2 -3
- package/runtime/skills/okstra-report-writer/SKILL.md +35 -15
- package/runtime/skills/okstra-run/SKILL.md +5 -4
- package/runtime/skills/okstra-schedule/SKILL.md +4 -4
- package/runtime/skills/okstra-setup/SKILL.md +27 -0
- package/runtime/skills/okstra-team-contract/SKILL.md +1 -1
- package/runtime/templates/okstra.CLAUDE.md +104 -0
- package/runtime/templates/reports/final-report.template.md +93 -98
- package/runtime/templates/reports/i18n/en.json +135 -0
- package/runtime/templates/reports/i18n/ko.json +135 -0
- package/runtime/templates/reports/implementation-planning-input.template.md +18 -0
- package/runtime/templates/reports/improvement-discovery-input.template.md +78 -0
- package/runtime/templates/reports/task-brief.template.md +2 -2
- package/runtime/validators/lib/fixtures.sh +30 -0
- package/runtime/validators/lib/runners.sh +1 -1
- package/runtime/validators/validate-implementation-plan-stages.py +211 -0
- package/runtime/validators/validate-run.py +121 -26
- package/runtime/validators/validate-workflow.sh +2 -2
- package/runtime/validators/validate_improvement_report.py +275 -0
- package/src/config.mjs +18 -0
- package/src/install.mjs +41 -14
- package/src/setup.mjs +133 -1
- 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
|
-
# 승인 게이트(`
|
|
148
|
+
# 승인 게이트(`frontmatter approved`)는 implementation 진입 직전에 한 번만 의미를 가진다.
|
|
148
149
|
# implementation run 이 검증을 통과했다는 것은 `_validate_approved_plan` 이 이미 사용자
|
|
149
|
-
#
|
|
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`
|
|
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 —
|
|
638
|
-
"
|
|
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
|
|
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
|
-
#
|
|
937
|
-
#
|
|
938
|
-
#
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
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
|
-
|
|
1186
|
-
|
|
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
|
|
1190
|
-
"
|
|
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
|
|
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
|
|
1196
|
-
"
|
|
1197
|
-
|
|
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
|
-
|
|
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 "$
|
|
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) {
|