okstra 0.25.1 → 0.27.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 (48) hide show
  1. package/README.kr.md +16 -0
  2. package/README.md +16 -0
  3. package/docs/kr/architecture.md +3 -7
  4. package/docs/kr/cli.md +47 -4
  5. package/docs/kr/performance-improvement-plan-v2.md +23 -0
  6. package/docs/kr/performance-improvement-plan.md +22 -0
  7. package/docs/superpowers/specs/2026-05-15-implementation-plan-verification-design.md +254 -0
  8. package/package.json +1 -1
  9. package/runtime/BUILD.json +2 -2
  10. package/runtime/agents/SKILL.md +30 -2
  11. package/runtime/bin/okstra.sh +1 -1
  12. package/runtime/prompts/profiles/_common-contract.md +30 -1
  13. package/runtime/prompts/profiles/error-analysis.md +12 -0
  14. package/runtime/prompts/profiles/implementation-planning.md +23 -0
  15. package/runtime/prompts/profiles/requirements-discovery.md +20 -0
  16. package/runtime/python/lib/okstra/cli.sh +8 -7
  17. package/runtime/python/lib/okstra/globals.sh +3 -1
  18. package/runtime/python/lib/okstra/usage.sh +8 -4
  19. package/runtime/python/okstra_ctl/render.py +35 -0
  20. package/runtime/python/okstra_ctl/run.py +27 -6
  21. package/runtime/python/okstra_ctl/run_context.py +1 -1
  22. package/runtime/python/okstra_ctl/wizard.py +259 -10
  23. package/runtime/python/okstra_token_usage/blocks.py +5 -1
  24. package/runtime/python/okstra_token_usage/claude.py +16 -1
  25. package/runtime/python/okstra_token_usage/collect.py +17 -3
  26. package/runtime/python/okstra_token_usage/pricing.py +159 -24
  27. package/runtime/skills/okstra-brief/SKILL.md +532 -65
  28. package/runtime/skills/okstra-context-loader/SKILL.md +25 -11
  29. package/runtime/skills/okstra-convergence/SKILL.md +235 -8
  30. package/runtime/skills/okstra-history/SKILL.md +68 -37
  31. package/runtime/skills/okstra-logs/SKILL.md +26 -4
  32. package/runtime/skills/okstra-report-finder/SKILL.md +49 -22
  33. package/runtime/skills/okstra-report-writer/SKILL.md +59 -64
  34. package/runtime/skills/okstra-run/SKILL.md +53 -39
  35. package/runtime/skills/okstra-schedule/SKILL.md +51 -20
  36. package/runtime/skills/okstra-setup/SKILL.md +31 -12
  37. package/runtime/skills/okstra-status/SKILL.md +20 -8
  38. package/runtime/skills/okstra-team-contract/SKILL.md +27 -15
  39. package/runtime/skills/okstra-time-summary/SKILL.md +53 -16
  40. package/runtime/templates/reports/final-report.template.md +34 -0
  41. package/runtime/templates/reports/settings.template.json +7 -4
  42. package/runtime/validators/lib/fixtures.sh +10 -2
  43. package/runtime/validators/lib/validate-assets.sh +50 -24
  44. package/runtime/validators/validate-brief.py +385 -0
  45. package/runtime/validators/validate-brief.sh +35 -0
  46. package/runtime/validators/validate-run.py +71 -0
  47. package/runtime/validators/validate-workflow.sh +7 -33
  48. package/src/wizard.mjs +21 -5
@@ -225,6 +225,40 @@ revert 경로와 롤백 트리거 신호를 표로 정리합니다. 추상적
225
225
  - 본 섹션에는 승인 결정에 영향을 주는 *플랜 측 보충 메모*만 적습니다(예: 위험을 줄이기 위한 사전 작업, 승인 전 사용자가 확인해 두어야 할 사항). 승인 마커는 본 섹션이 아니라 상단 블록의 체크박스로만 부여합니다.
226
226
  - 사용자가 답하거나 자료를 첨부해야만 승인이 가능한 항목은 **이 섹션에 적지 않습니다** — `## 5. Clarification Items` 표에 한 행으로 등록하고 `Blocks=approval` 로 표시하세요. 같은 항목을 두 위치에 적으면 sync 가 깨집니다.
227
227
 
228
+ ### 4.5.9 Plan Body Verification (워커 사후 검증)
229
+
230
+ > **이 sub-section 은 `task-type` = `implementation-planning` 실행 결과에만 포함하세요.** Phase 6 에서 report-writer 가 합성한 4.5 본문(Option Candidates / Stepwise Execution Order / Dependency / Validation Checklist / Rollback)을 lead 가 plan-item 단위로 쪼개 워커들에게 다시 던지고 `AGREE / DISAGREE / SUPPLEMENT` 평결을 수집한 결과입니다. 자세한 라운드 프로토콜은 `skills/okstra-convergence/SKILL.md` "Plan-body verification mode" 섹션을 참고하세요.
231
+
232
+ - **Round count**: `<N>` (default: 1)
233
+ - **Gate result**: `<passed | passed-with-dissent | blocked-by-disagreement | aborted-non-result>`
234
+ - `passed` → 본 보고서 상단 `User Approval Request` 체크박스가 렌더됩니다.
235
+ - `passed-with-dissent` → 상단 체크박스가 렌더되되, 반대 의견은 아래 `Dissent log` 에 기록.
236
+ - `blocked-by-disagreement` → 상단 체크박스 라인 자체를 **렌더하지 않습니다**. majority DISAGREE 인 plan item 마다 `## 5. Clarification Items` 에 `Blocks=approval` row 가 추가되며, 사용자가 응답해야 다음 phase 로 넘어갈 수 있습니다.
237
+ - `aborted-non-result` → 워커 dispatch 가 모두 non-result (timeout / error). 상단 체크박스 라인 렌더하지 않음 + `## 5. Clarification Items` 에 "plan-body verification could not run" row 가 추가됩니다.
238
+
239
+ #### Verdict table
240
+
241
+ 각 plan item 1 행. `Plan item` 열은 `P-Opt-<N>` / `P-Step-<N>` / `P-Dep-<N>` / `P-Val-<N>` / `P-Rb-<N>` prefix 사용. `Section` 열은 4.5 내부 sub-section 번호 (예: `4.5.4`).
242
+
243
+ | Plan item | Ticket ID | Section | <worker1> | <worker2> | ... | Classification |
244
+ |-----------|-----------|---------|-----------|-----------|-----|----------------|
245
+ | P-Opt-1 | `<id>` | 4.5.1 | AGREE | AGREE | ... | full-consensus |
246
+ | P-Step-3 | `<id>` | 4.5.4 | DISAGREE(a) | DISAGREE(a) | ... | majority-disagree → C-7 |
247
+
248
+ - DISAGREE 셀에는 spec 의 `(a|b|c|d|e)` breakage kind 를 함께 표기 (예: `DISAGREE(a)` = 참조 file path / symbol 불일치).
249
+ - 마지막 열 `Classification` ∈ `{full-consensus, partial-consensus, worker-unique, majority-disagree → C-<N>}`. `majority-disagree → C-<N>` 의 `C-<N>` 은 본 보고서 `## 5. Clarification Items` 표에서 해당 변환 row 의 ID 와 일치해야 합니다.
250
+
251
+ #### Dissent log
252
+
253
+ `partial-consensus` 와 `worker-unique` 로 분류된 plan item 의 반대 의견을 plan item 별로 묶어 적습니다. `majority-disagree` 항목의 반대 의견은 본 섹션 대신 `## 5. Clarification Items` 의 해당 row 의 `Statement` 컬럼에 옮겨 적습니다.
254
+
255
+ - **P-XXX-N**: `<worker-role>` — `<반대 의견 본문 2-3 sentences>`
256
+
257
+ #### Notes
258
+
259
+ - 본 sub-section 이 누락된 채로 task-type = implementation-planning final report 가 생성되면 validator 가 `contract-violated` 로 종료합니다.
260
+ - `Gate result` 가 `blocked-by-disagreement` / `aborted-non-result` 인데 상단 `User Approval Request` 체크박스 라인이 존재하면 동일하게 contract violation 입니다.
261
+
228
262
  ## 4.6 Release Handoff Deliverables (release-handoff runs only)
229
263
 
230
264
  > **이 섹션은 `task-type` = `release-handoff` 실행 결과에만 포함하세요. 다른 task-type에서는 섹션 전체를 삭제하고 5번 섹션으로 넘어갑니다.**
@@ -131,14 +131,14 @@
131
131
  "Bash(codex exec:*)",
132
132
  "Bash(okstra)",
133
133
  "Bash(okstra:*)",
134
+ "Bash(npx okstra@latest:*)",
135
+ "Bash(npx -y okstra@latest:*)",
134
136
  "Bash($HOME/.okstra/bin/:*)",
137
+ "Bash(STATE_FILE=:*)",
135
138
 
136
139
  "Bash(gemini)",
137
140
  "Bash(gemini:*)",
138
141
 
139
- "Bash($HOME/.okstra/bin/okstra-trace-cleanup.sh)",
140
- "Bash($HOME/.okstra/bin/okstra-trace-cleanup.sh:*)",
141
-
142
142
  "Bash(claude)",
143
143
  "Bash(claude:*)",
144
144
 
@@ -150,7 +150,10 @@
150
150
  "SessionEnd": [
151
151
  {
152
152
  "hooks": [
153
- { "type": "command", "command": "$HOME/.okstra/bin/okstra-trace-cleanup.sh" }
153
+ {
154
+ "type": "command",
155
+ "command": "$HOME/.okstra/bin/okstra-trace-cleanup.sh"
156
+ }
154
157
  ]
155
158
  }
156
159
  ]
@@ -51,8 +51,7 @@ write_validation_brief() {
51
51
 
52
52
  - Config file: \`.claude/settings.json\`
53
53
  - Expected values:
54
- - project-local okstra Claude assets must remain discoverable under \`.claude/skills/\` and \`.claude/agents/\`
55
- - refresh should occur only when \`--refresh-assets\` is used
54
+ - installed okstra Claude assets must remain discoverable under \`~/.claude/skills/\` and \`~/.claude/agents/\` (managed by \`okstra install\`)
56
55
  - Config file: \`.project-docs/okstra/discovery/latest-task.json\`
57
56
  - Expected values:
58
57
  - latest prepared task pointer must include the current task key
@@ -257,6 +256,15 @@ if isinstance(lead, dict):
257
256
  lead["status"] = "completed"
258
257
  team_state["workflowState"] = "worker-results-collected"
259
258
 
259
+ # validate-run.py requires team-state.teamCreate.attempted=true with a
260
+ # status of ok|error once any worker has been dispatched (see
261
+ # validators/validate-run.py:334-337). Mirror that here so the fixture
262
+ # represents a valid post-Phase-3 state.
263
+ team_state["teamCreate"] = {
264
+ "attempted": True,
265
+ "status": "ok",
266
+ }
267
+
260
268
  # Phase 7 token-usage collection is normally produced by okstra-token-usage.py.
261
269
  # The validator (`team-state.usageSummary is empty`) treats absence as a contract
262
270
  # violation, so the fixture must mirror that step with a synthetic-but-valid object.
@@ -1,40 +1,66 @@
1
1
  # shellcheck shell=bash
2
2
 
3
+ # Verify that the npm build output under `runtime/` is fresh and matches the
4
+ # source files that `okstra install` (src/install.mjs) copies into the user's
5
+ # `~/.claude/skills/`, `~/.claude/agents/`, and `~/.okstra/` directories.
6
+ #
7
+ # Historically this validator checked a *project-local* `.claude/skills/` +
8
+ # `.claude/agents/` tree that `okstra.sh` seeded into the project root on
9
+ # every run. That seeding step was removed when install moved into
10
+ # `src/install.mjs`; install now writes only to the user's
11
+ # `$HOME/.claude` + `$HOME/.okstra`, never to the project root. The runtime/
12
+ # tree is the canonical staging area that `okstra install` rsyncs from, so we
13
+ # validate parity there instead.
3
14
  validate_seeded_assets() {
4
15
  local validation_mode="$1"
16
+ local runtime_root="$WORKSPACE_ROOT/runtime"
5
17
 
6
- python3 - "$SOURCE_ASSET_ROOT" "$PROJECT_ROOT/.claude" "$validation_mode" <<'PY'
18
+ if [[ ! -d "$runtime_root" ]]; then
19
+ printf 'runtime/ build output is missing — run `npm run build` before validating.\n' >&2
20
+ return 1
21
+ fi
22
+
23
+ python3 - "$SOURCE_ASSET_ROOT" "$WORKSPACE_ROOT/skills" "$runtime_root" "$validation_mode" <<'PY'
7
24
  from pathlib import Path
8
25
  import sys
9
26
 
10
- source_root = Path(sys.argv[1])
11
- target_root = Path(sys.argv[2])
12
- validation_mode = sys.argv[3]
27
+ agents_source_root = Path(sys.argv[1]) # <repo>/agents
28
+ skills_source_root = Path(sys.argv[2]) # <repo>/skills
29
+ runtime_root = Path(sys.argv[3]) # <repo>/runtime
30
+ validation_mode = sys.argv[4]
13
31
  errors = []
14
32
 
15
- for source_path in sorted(source_root.rglob("*.md")):
16
- relative_path = source_path.relative_to(source_root)
17
- parts = relative_path.parts
18
-
19
- if relative_path.as_posix() == "SKILL.md":
20
- target_path = target_root / "skills" / "okstra" / "SKILL.md"
21
- elif parts[0] == "skills":
22
- target_path = target_root / "skills" / Path(*parts[1:])
23
- elif parts[0] == "workers":
24
- # `agents/workers/<name>.md` 는 `.claude/agents/<name>.md` 로 시드된다.
25
- # seeding.sh 의 분기와 동일하게 유지해야 한다.
26
- target_path = target_root / "agents" / Path(*parts[1:])
27
- elif parts[0] == "agents":
28
- target_path = target_root / "agents" / Path(*parts[1:])
29
- else:
30
- target_path = target_root / "skills" / "okstra" / relative_path
31
33
 
34
+ def check(source_path: Path, target_path: Path) -> None:
32
35
  if not target_path.is_file():
33
- errors.append(f"missing seeded asset: {target_path}")
34
- continue
35
-
36
+ errors.append(f"missing build-output asset: {target_path}")
37
+ return
36
38
  if validation_mode == "match" and target_path.read_bytes() != source_path.read_bytes():
37
- errors.append(f"seeded asset content does not match source: {target_path}")
39
+ errors.append(f"build-output asset does not match source: {target_path}")
40
+
41
+
42
+ # 1. Worker agent files: agents/workers/*-worker.md -> runtime/agents/workers/*-worker.md
43
+ workers_source = agents_source_root / "workers"
44
+ workers_target = runtime_root / "agents" / "workers"
45
+ if not workers_source.is_dir():
46
+ errors.append(f"missing agents/workers source directory: {workers_source}")
47
+ else:
48
+ for source_path in sorted(workers_source.glob("*.md")):
49
+ check(source_path, workers_target / source_path.name)
50
+
51
+ # 2. Lead agent SKILL.md: agents/SKILL.md -> runtime/agents/SKILL.md
52
+ lead_source = agents_source_root / "SKILL.md"
53
+ if lead_source.is_file():
54
+ check(lead_source, runtime_root / "agents" / "SKILL.md")
55
+
56
+ # 3. Skill packages: skills/<name>/SKILL.md -> runtime/skills/<name>/SKILL.md
57
+ if not skills_source_root.is_dir():
58
+ errors.append(f"missing skills source directory: {skills_source_root}")
59
+ else:
60
+ for skill_dir in sorted(p for p in skills_source_root.iterdir() if p.is_dir()):
61
+ for source_path in sorted(skill_dir.rglob("*.md")):
62
+ relative = source_path.relative_to(skills_source_root)
63
+ check(source_path, runtime_root / "skills" / relative)
38
64
 
39
65
  if errors:
40
66
  for error in errors:
@@ -0,0 +1,385 @@
1
+ #!/usr/bin/env python3
2
+ """Validate brief markdown files produced by the okstra-brief skill.
3
+
4
+ Checks performed per brief file:
5
+
6
+ 1. YAML frontmatter exists on line 1 with required keys.
7
+ 2. brief-id matches the filename stem.
8
+ 3. depth equals the number of `sub/` segments in the path (relative to the
9
+ `briefs/` root).
10
+ 4. Every Open Questions row starts with one of the five signal prefixes
11
+ (general | terminology | intent-check | conversion-block | adr-candidate).
12
+ `adr-candidate:` targets okstra-internal
13
+ `<PROJECT_ROOT>/.project-docs/okstra/decisions/`, not external `docs/adr/`.
14
+ 5. Every Augmentation entry (inline `> augmented: <label>` blockquotes and
15
+ `Augmentation` section bullets) carries one of the four labels
16
+ (evidence-link | format-conversion | terminology-mapping | intent-inference).
17
+ Both documented forms are accepted: `label: ...` and `label — ...`.
18
+ 6. Every `intent-inference` augmentation has a corresponding
19
+ `intent-check:` row in Open Questions (auto-mirroring rule).
20
+ 7. Every `terminology-mapping` augmentation (excluding Step 4.5 outcome
21
+ markers `applied glossary:` / `skipped glossary:`) has a corresponding
22
+ `terminology:` row in Open Questions.
23
+ 8. `parent-id` chain: at depth 0 the value MUST be the literal `self`;
24
+ at depth ≥ 1 it MUST NOT be `self` and MUST differ from the brief's
25
+ own `brief-id`.
26
+ 9. `reporter-confirmations` consistency: when `complete`, every
27
+ `intent-check:` and `conversion-block:` row in Open Questions MUST
28
+ carry a `[CONFIRMED YYYY-MM-DD → RC-N]` marker.
29
+
30
+ Exit code 0 on PASS, 1 on FAIL.
31
+ """
32
+
33
+ from __future__ import annotations
34
+
35
+ import argparse
36
+ import re
37
+ import sys
38
+ from pathlib import Path
39
+ from typing import Iterable
40
+
41
+ REQUIRED_FRONTMATTER_KEYS = {
42
+ "type",
43
+ "brief-id",
44
+ "parent-id",
45
+ "ticket-id",
46
+ "source-type",
47
+ "task-group",
48
+ "depth",
49
+ "created",
50
+ "generator",
51
+ "reporter-confirmations",
52
+ }
53
+
54
+ OPEN_QUESTIONS_PREFIXES = {
55
+ "general:",
56
+ "terminology:",
57
+ "intent-check:",
58
+ "conversion-block:",
59
+ "adr-candidate:",
60
+ }
61
+
62
+ AUGMENTATION_LABELS = {
63
+ "evidence-link",
64
+ "format-conversion",
65
+ "terminology-mapping",
66
+ "intent-inference",
67
+ }
68
+
69
+ REPORTER_CONFIRMATION_VALUES = {"complete", "partial", "pending", "skipped"}
70
+
71
+
72
+ def parse_frontmatter(text: str) -> tuple[dict[str, str], int]:
73
+ """Return (frontmatter dict, line after closing `---`)."""
74
+ lines = text.splitlines()
75
+ if not lines or lines[0].strip() != "---":
76
+ raise ValueError("missing opening frontmatter delimiter on line 1")
77
+ out: dict[str, str] = {}
78
+ for idx in range(1, len(lines)):
79
+ line = lines[idx]
80
+ if line.strip() == "---":
81
+ return out, idx + 1
82
+ # naive key: value (comments after #)
83
+ bare = line.split("#", 1)[0].strip()
84
+ if not bare:
85
+ continue
86
+ if ":" not in bare:
87
+ raise ValueError(f"frontmatter line without colon: {line!r}")
88
+ key, _, value = bare.partition(":")
89
+ out[key.strip()] = value.strip()
90
+ raise ValueError("missing closing frontmatter delimiter")
91
+
92
+
93
+ def section_body(text: str, heading: str) -> str:
94
+ """Return the body lines between `## <heading>` and the next `## ` heading."""
95
+ pattern = re.compile(
96
+ r"^##\s+" + re.escape(heading) + r"\s*$(.*?)(?=^##\s|\Z)",
97
+ re.MULTILINE | re.DOTALL,
98
+ )
99
+ match = pattern.search(text)
100
+ if not match:
101
+ return ""
102
+ return match.group(1)
103
+
104
+
105
+ def is_placeholder(line: str) -> bool:
106
+ bare = line.strip().lstrip("-").strip()
107
+ return bare in {"_(none)_", "_(none — pending or skipped)_", ""}
108
+
109
+
110
+ def is_template_example(line: str) -> bool:
111
+ """Lines that are template scaffolding (placeholder/example), not real entries."""
112
+ bare = line.strip().lstrip("-").strip()
113
+ return bare.startswith("<") and bare.endswith(">")
114
+
115
+
116
+ def open_questions_rows(text: str) -> list[str]:
117
+ body = section_body(text, "Open Questions")
118
+ rows: list[str] = []
119
+ for line in body.splitlines():
120
+ stripped = line.strip()
121
+ if not stripped.startswith("- "):
122
+ continue
123
+ content = stripped[2:].strip()
124
+ if is_placeholder(content) or is_template_example(content):
125
+ continue
126
+ # strip backticks if the row body is wrapped in `…`
127
+ content = content.strip("`")
128
+ rows.append(content)
129
+ return rows
130
+
131
+
132
+ def augmentation_entries(text: str) -> list[str]:
133
+ """Bullets under the `## Augmentation` section (entries that look like real data)."""
134
+ body = section_body(text, "Augmentation")
135
+ entries: list[str] = []
136
+ for line in body.splitlines():
137
+ stripped = line.strip()
138
+ if not stripped.startswith("- "):
139
+ continue
140
+ content = stripped[2:].strip()
141
+ if is_placeholder(content) or is_template_example(content):
142
+ continue
143
+ # strip backticks
144
+ content = content.strip("`")
145
+ entries.append(content)
146
+ return entries
147
+
148
+
149
+ def inline_augmented_blockquotes(text: str) -> list[str]:
150
+ """Lines starting with `> augmented:`."""
151
+ out: list[str] = []
152
+ for line in text.splitlines():
153
+ stripped = line.strip()
154
+ if stripped.startswith("> augmented:"):
155
+ payload = stripped[len("> augmented:"):].strip()
156
+ if payload.startswith("<") and payload.endswith(">"):
157
+ # template scaffold, e.g. `> augmented: <label> — <interpretation>`
158
+ continue
159
+ out.append(payload)
160
+ return out
161
+
162
+
163
+ def parse_augmentation_label(entry: str) -> tuple[str | None, str]:
164
+ """Return (label, payload) for documented augmentation forms."""
165
+ stripped = entry.strip()
166
+ for label in AUGMENTATION_LABELS:
167
+ if stripped == label:
168
+ return label, ""
169
+ for sep in (":", " — ", " - "):
170
+ prefix = f"{label}{sep}"
171
+ if stripped.startswith(prefix):
172
+ return label, stripped[len(prefix):].strip()
173
+ return None, stripped
174
+
175
+
176
+ def validate_brief(path: Path, briefs_root: Path) -> list[str]:
177
+ text = path.read_text(encoding="utf-8")
178
+ errors: list[str] = []
179
+
180
+ # 1. frontmatter
181
+ try:
182
+ fm, _ = parse_frontmatter(text)
183
+ except ValueError as exc:
184
+ return [f"frontmatter: {exc}"]
185
+
186
+ missing = REQUIRED_FRONTMATTER_KEYS - fm.keys()
187
+ if missing:
188
+ errors.append(f"frontmatter missing keys: {sorted(missing)}")
189
+
190
+ if fm.get("type") != "brief":
191
+ errors.append(f"frontmatter type must be 'brief', got {fm.get('type')!r}")
192
+
193
+ if fm.get("generator") != "okstra-brief":
194
+ errors.append(
195
+ f"frontmatter generator must be 'okstra-brief', got {fm.get('generator')!r}"
196
+ )
197
+
198
+ if fm.get("reporter-confirmations") not in REPORTER_CONFIRMATION_VALUES:
199
+ errors.append(
200
+ "frontmatter reporter-confirmations must be one of "
201
+ f"{sorted(REPORTER_CONFIRMATION_VALUES)}, got "
202
+ f"{fm.get('reporter-confirmations')!r}"
203
+ )
204
+
205
+ # 2. brief-id matches filename stem
206
+ stem = path.stem
207
+ if fm.get("brief-id") and fm["brief-id"] != stem:
208
+ errors.append(
209
+ f"brief-id {fm['brief-id']!r} does not match filename stem {stem!r}"
210
+ )
211
+
212
+ # 3. depth equals path's `sub/` nesting depth
213
+ try:
214
+ rel = path.relative_to(briefs_root)
215
+ except ValueError:
216
+ rel = path
217
+ # path components after the task-group dir: any number of `sub` segments + filename
218
+ parts = list(rel.parts)
219
+ if len(parts) >= 2:
220
+ nested = [p for p in parts[1:-1] if p == "sub"]
221
+ expected_depth = len(nested)
222
+ try:
223
+ actual_depth = int(fm.get("depth", "0"))
224
+ except ValueError:
225
+ actual_depth = -1
226
+ if actual_depth != expected_depth:
227
+ errors.append(
228
+ f"depth mismatch: path has {expected_depth} `sub/` segments, "
229
+ f"frontmatter says depth={fm.get('depth')!r}"
230
+ )
231
+
232
+ # 4. Open Questions prefixes
233
+ oq_rows = open_questions_rows(text)
234
+ intent_check_rows: list[str] = []
235
+ terminology_rows: list[str] = []
236
+ conversion_block_rows: list[str] = []
237
+ for row in oq_rows:
238
+ if not any(row.startswith(prefix) for prefix in OPEN_QUESTIONS_PREFIXES):
239
+ errors.append(f"Open Questions row lacks a known prefix: {row!r}")
240
+ if row.startswith("intent-check:"):
241
+ intent_check_rows.append(row)
242
+ elif row.startswith("terminology:"):
243
+ terminology_rows.append(row)
244
+ elif row.startswith("conversion-block:"):
245
+ conversion_block_rows.append(row)
246
+
247
+ # 5. Augmentation labels
248
+ augmentation_lines: list[str] = []
249
+ augmentation_lines.extend(augmentation_entries(text))
250
+ augmentation_lines.extend(inline_augmented_blockquotes(text))
251
+ intent_inference_count = 0
252
+ terminology_mapping_count = 0
253
+ for entry in augmentation_lines:
254
+ label, payload = parse_augmentation_label(entry)
255
+ if label not in AUGMENTATION_LABELS:
256
+ errors.append(
257
+ f"Augmentation entry lacks a known label: {entry!r} "
258
+ f"(label parsed as {label!r})"
259
+ )
260
+ continue
261
+ if label == "intent-inference":
262
+ intent_inference_count += 1
263
+ elif label == "terminology-mapping":
264
+ # Step 4.5 outcome markers do not need a paired Open Questions row.
265
+ if payload.startswith("applied glossary:") or payload.startswith(
266
+ "skipped glossary:"
267
+ ):
268
+ continue
269
+ terminology_mapping_count += 1
270
+
271
+ # 6. auto-mirroring rule (intent-inference ↔ intent-check:)
272
+ if intent_inference_count > len(intent_check_rows):
273
+ errors.append(
274
+ f"intent-inference augmentations present ({intent_inference_count}) "
275
+ f"but only {len(intent_check_rows)} intent-check: row(s) in Open Questions"
276
+ )
277
+
278
+ # 7. dual-record rule (terminology-mapping ↔ terminology:)
279
+ if terminology_mapping_count > 0 and not terminology_rows:
280
+ errors.append(
281
+ f"terminology-mapping augmentations present ({terminology_mapping_count}) "
282
+ f"but no terminology: row(s) in Open Questions"
283
+ )
284
+
285
+ # 8. parent-id chain
286
+ parent_id = fm.get("parent-id", "")
287
+ brief_id = fm.get("brief-id", "")
288
+ try:
289
+ depth_value = int(fm.get("depth", "0"))
290
+ except ValueError:
291
+ depth_value = -1
292
+ if depth_value == 0:
293
+ if parent_id != "self":
294
+ errors.append(
295
+ f"parent-id for the root (depth 0) brief must be 'self', "
296
+ f"got {parent_id!r}"
297
+ )
298
+ elif depth_value > 0:
299
+ if parent_id == "self":
300
+ errors.append(
301
+ f"parent-id for a descendant (depth {depth_value}) brief must not be 'self'"
302
+ )
303
+ elif parent_id == brief_id:
304
+ errors.append(
305
+ f"parent-id for a descendant brief must differ from its own brief-id "
306
+ f"({brief_id!r})"
307
+ )
308
+
309
+ # 9. reporter-confirmations consistency
310
+ rc_status = fm.get("reporter-confirmations")
311
+ if rc_status == "complete":
312
+ unconfirmed = [
313
+ row
314
+ for row in (intent_check_rows + conversion_block_rows)
315
+ if "[CONFIRMED" not in row
316
+ ]
317
+ if unconfirmed:
318
+ sample = unconfirmed[0]
319
+ errors.append(
320
+ f"reporter-confirmations is 'complete' but {len(unconfirmed)} "
321
+ f"intent-check:/conversion-block: row(s) lack a [CONFIRMED …] "
322
+ f"marker (e.g. {sample!r})"
323
+ )
324
+
325
+ return errors
326
+
327
+
328
+ def find_briefs(root: Path) -> Iterable[Path]:
329
+ yield from root.rglob("*.md")
330
+
331
+
332
+ def main(argv: list[str] | None = None) -> int:
333
+ parser = argparse.ArgumentParser(description=__doc__)
334
+ parser.add_argument(
335
+ "briefs_dir",
336
+ type=Path,
337
+ help="Directory containing brief markdown files (recursed).",
338
+ )
339
+ parser.add_argument(
340
+ "--briefs-root",
341
+ type=Path,
342
+ default=None,
343
+ help=(
344
+ "Root used for depth computation (defaults to briefs_dir). "
345
+ "Usually `<PROJECT_ROOT>/.project-docs/okstra/briefs`."
346
+ ),
347
+ )
348
+ args = parser.parse_args(argv)
349
+
350
+ briefs_dir: Path = args.briefs_dir
351
+ if not briefs_dir.exists():
352
+ print(f"[FAIL] briefs directory not found: {briefs_dir}", file=sys.stderr)
353
+ return 1
354
+
355
+ briefs_root: Path = args.briefs_root or briefs_dir
356
+
357
+ total = 0
358
+ failed_files: list[tuple[Path, list[str]]] = []
359
+ for brief in find_briefs(briefs_dir):
360
+ total += 1
361
+ errors = validate_brief(brief, briefs_root)
362
+ if errors:
363
+ failed_files.append((brief, errors))
364
+
365
+ if total == 0:
366
+ print(f"[PASS] no briefs found under {briefs_dir} (nothing to validate)")
367
+ return 0
368
+
369
+ if not failed_files:
370
+ print(f"[PASS] {total} brief(s) validated under {briefs_dir}")
371
+ return 0
372
+
373
+ for path, errors in failed_files:
374
+ print(f"[FAIL] {path}")
375
+ for err in errors:
376
+ print(f" - {err}")
377
+ print(
378
+ f"[FAIL] {len(failed_files)}/{total} brief(s) failed validation",
379
+ file=sys.stderr,
380
+ )
381
+ return 1
382
+
383
+
384
+ if __name__ == "__main__":
385
+ raise SystemExit(main())
@@ -0,0 +1,35 @@
1
+ #!/usr/bin/env bash
2
+ #
3
+ # Validate brief markdown files produced by okstra-brief.
4
+ #
5
+ # Usage:
6
+ # validators/validate-brief.sh <briefs-dir> [--briefs-root <dir>]
7
+ #
8
+ # Typical invocation (inside a project that has run okstra-setup):
9
+ # validators/validate-brief.sh "$PROJECT_ROOT/.project-docs/okstra/briefs"
10
+ #
11
+ # Thin bash entrypoint — delegates to validate-brief.py for content checks.
12
+
13
+ set -euo pipefail
14
+
15
+ SOURCE_PATH="${BASH_SOURCE[0]}"
16
+ while [[ -L "$SOURCE_PATH" ]]; do
17
+ SOURCE_DIR="$(cd -P "$(dirname "$SOURCE_PATH")" && pwd)"
18
+ SOURCE_PATH="$(readlink "$SOURCE_PATH")"
19
+ [[ "$SOURCE_PATH" != /* ]] && SOURCE_PATH="$SOURCE_DIR/$SOURCE_PATH"
20
+ done
21
+
22
+ SCRIPT_DIR="$(cd -P "$(dirname "$SOURCE_PATH")" && pwd)"
23
+ PYTHON_VALIDATOR="$SCRIPT_DIR/validate-brief.py"
24
+
25
+ if [[ ! -f "$PYTHON_VALIDATOR" ]]; then
26
+ echo "[FAIL] python helper not found: $PYTHON_VALIDATOR" >&2
27
+ exit 1
28
+ fi
29
+
30
+ if [[ $# -lt 1 ]]; then
31
+ echo "usage: $0 <briefs-dir> [--briefs-root <dir>]" >&2
32
+ exit 1
33
+ fi
34
+
35
+ exec python3 "$PYTHON_VALIDATOR" "$@"