okstra 0.26.0 → 0.28.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 +15 -0
- package/README.md +15 -0
- package/docs/kr/architecture.md +2 -6
- package/docs/kr/cli.md +40 -6
- package/docs/kr/performance-improvement-plan-v2.md +23 -0
- package/docs/kr/performance-improvement-plan.md +22 -0
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/agents/workers/claude-worker.md +4 -3
- package/runtime/agents/workers/codex-worker.md +4 -3
- package/runtime/agents/workers/gemini-worker.md +4 -3
- package/runtime/agents/workers/report-writer-worker.md +7 -2
- package/runtime/bin/okstra.sh +0 -1
- package/runtime/prompts/launch.template.md +1 -1
- package/runtime/prompts/profiles/_common-contract.md +36 -4
- package/runtime/prompts/profiles/error-analysis.md +12 -0
- package/runtime/prompts/profiles/implementation-planning.md +20 -0
- package/runtime/prompts/profiles/requirements-discovery.md +20 -0
- package/runtime/python/lib/okstra/cli.sh +1 -7
- package/runtime/python/lib/okstra/globals.sh +0 -1
- package/runtime/python/lib/okstra/usage.sh +1 -4
- package/runtime/python/okstra_ctl/render.py +3 -0
- package/runtime/python/okstra_ctl/run.py +0 -6
- package/runtime/python/okstra_ctl/run_context.py +1 -1
- package/runtime/python/okstra_ctl/wizard.py +25 -2
- package/runtime/python/okstra_token_usage/blocks.py +5 -1
- package/runtime/python/okstra_token_usage/claude.py +16 -1
- package/runtime/python/okstra_token_usage/cli.py +9 -2
- package/runtime/python/okstra_token_usage/collect.py +17 -3
- package/runtime/python/okstra_token_usage/pricing.py +159 -24
- package/runtime/python/okstra_token_usage/report.py +32 -3
- package/runtime/skills/okstra-brief/SKILL.md +532 -65
- package/runtime/skills/okstra-context-loader/SKILL.md +25 -11
- package/runtime/skills/okstra-convergence/SKILL.md +38 -14
- package/runtime/skills/okstra-history/SKILL.md +68 -37
- package/runtime/skills/okstra-logs/SKILL.md +26 -4
- package/runtime/skills/okstra-report-finder/SKILL.md +49 -22
- package/runtime/skills/okstra-report-writer/SKILL.md +62 -65
- package/runtime/skills/okstra-run/SKILL.md +35 -34
- package/runtime/skills/okstra-schedule/SKILL.md +51 -20
- package/runtime/skills/okstra-setup/SKILL.md +31 -12
- package/runtime/skills/okstra-status/SKILL.md +20 -8
- package/runtime/skills/okstra-team-contract/SKILL.md +41 -25
- package/runtime/skills/okstra-time-summary/SKILL.md +53 -16
- package/runtime/templates/reports/final-report.template.md +227 -207
- package/runtime/templates/reports/settings.template.json +7 -4
- package/runtime/validators/lib/fixtures.sh +47 -2
- package/runtime/validators/lib/validate-assets.sh +50 -24
- package/runtime/validators/validate-brief.py +385 -0
- package/runtime/validators/validate-brief.sh +35 -0
- package/runtime/validators/validate-run.py +313 -1
- package/runtime/validators/validate-workflow.sh +7 -33
|
@@ -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
|
-
{
|
|
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
|
-
-
|
|
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
|
|
@@ -251,12 +250,39 @@ for worker in team_state.get("workers", []):
|
|
|
251
250
|
)
|
|
252
251
|
+ "\n"
|
|
253
252
|
)
|
|
253
|
+
# Mirror the audit sidecar contract — every completed worker-results
|
|
254
|
+
# file ships alongside `<worker>-audit-<task-type>-<seq>.md` carrying
|
|
255
|
+
# the Reading Confirmation block. Derive the sidecar path by
|
|
256
|
+
# inserting `-audit` after the worker-role segment of the
|
|
257
|
+
# result-file stem.
|
|
258
|
+
result_stem = result_path.stem # e.g. claude-worker-error-analysis-001
|
|
259
|
+
audit_stem = result_stem.replace("-worker-", "-worker-audit-", 1)
|
|
260
|
+
audit_path = result_path.with_name(f"{audit_stem}{result_path.suffix}")
|
|
261
|
+
audit_path.write_text(
|
|
262
|
+
"\n".join(
|
|
263
|
+
[
|
|
264
|
+
f"# {worker.get('role', worker_id)} Audit",
|
|
265
|
+
"",
|
|
266
|
+
"- Read task-brief.md end-to-end (validation fixture).",
|
|
267
|
+
]
|
|
268
|
+
)
|
|
269
|
+
+ "\n"
|
|
270
|
+
)
|
|
254
271
|
|
|
255
272
|
lead = team_state.get("lead")
|
|
256
273
|
if isinstance(lead, dict):
|
|
257
274
|
lead["status"] = "completed"
|
|
258
275
|
team_state["workflowState"] = "worker-results-collected"
|
|
259
276
|
|
|
277
|
+
# validate-run.py requires team-state.teamCreate.attempted=true with a
|
|
278
|
+
# status of ok|error once any worker has been dispatched (see
|
|
279
|
+
# validators/validate-run.py:334-337). Mirror that here so the fixture
|
|
280
|
+
# represents a valid post-Phase-3 state.
|
|
281
|
+
team_state["teamCreate"] = {
|
|
282
|
+
"attempted": True,
|
|
283
|
+
"status": "ok",
|
|
284
|
+
}
|
|
285
|
+
|
|
260
286
|
# Phase 7 token-usage collection is normally produced by okstra-token-usage.py.
|
|
261
287
|
# The validator (`team-state.usageSummary is empty`) treats absence as a contract
|
|
262
288
|
# violation, so the fixture must mirror that step with a synthetic-but-valid object.
|
|
@@ -297,6 +323,16 @@ if not isinstance(required_status_entries, list):
|
|
|
297
323
|
report_lines = [
|
|
298
324
|
"# Validation Fixture Report",
|
|
299
325
|
"",
|
|
326
|
+
"## Verdict Card",
|
|
327
|
+
"",
|
|
328
|
+
"| 항목 | 값 |",
|
|
329
|
+
"|------|----|",
|
|
330
|
+
"| Final Conclusion | validation fixture |",
|
|
331
|
+
"| Verdict Token | `not-applicable` |",
|
|
332
|
+
"| Direction | `continue-investigation` |",
|
|
333
|
+
"| Approval Required? | `no` |",
|
|
334
|
+
"| Next Step | fixture |",
|
|
335
|
+
"",
|
|
300
336
|
"## Agent Execution Status",
|
|
301
337
|
]
|
|
302
338
|
for label in required_status_entries:
|
|
@@ -304,6 +340,15 @@ for label in required_status_entries:
|
|
|
304
340
|
report_lines.append(f"- {label}: fixture status recorded")
|
|
305
341
|
report_lines.extend(
|
|
306
342
|
[
|
|
343
|
+
"",
|
|
344
|
+
"## Token Usage Summary",
|
|
345
|
+
"",
|
|
346
|
+
"| 항목 | 처리 토큰 | 환산 토큰 | 비용 (USD) |",
|
|
347
|
+
"|------|-----------|-----------|------------|",
|
|
348
|
+
"| Lead | `1` | `1` | `$0.01` |",
|
|
349
|
+
"| Worker 합계 | `1` | `1` | `$0.01` |",
|
|
350
|
+
"| **전체 합계** | **`2`** | **`2`** | **`$0.02`** |",
|
|
351
|
+
"| Codex/Gemini CLI 추가 비용 | | | `$0.00` |",
|
|
307
352
|
"",
|
|
308
353
|
"## Final Verdict",
|
|
309
354
|
"- Validation fixture report generated.",
|
|
@@ -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
|
-
|
|
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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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
|
|
34
|
-
|
|
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"
|
|
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" "$@"
|