okstra 0.83.0 → 0.84.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 (20) hide show
  1. package/package.json +1 -1
  2. package/runtime/BUILD.json +2 -2
  3. package/runtime/prompts/okstra-coding-preflight/scripts/preedit-check.sh +79 -0
  4. package/runtime/python/okstra_ctl/run.py +3 -1
  5. package/runtime/skills/okstra-brief/SKILL.md +65 -11
  6. package/runtime/skills/okstra-inspect/SKILL.md +1 -1
  7. package/runtime/skills/okstra-schedule/SKILL.md +4 -4
  8. package/runtime/skills/okstra-setup/SKILL.md +2 -2
  9. package/runtime/templates/reports/settings.template.json +14 -123
  10. package/runtime/validators/validate-brief.py +99 -16
  11. /package/runtime/prompts/{coding-preflight → okstra-coding-preflight}/architectures/hexagonal.md +0 -0
  12. /package/runtime/prompts/{coding-preflight → okstra-coding-preflight}/clean-code.md +0 -0
  13. /package/runtime/prompts/{coding-preflight → okstra-coding-preflight}/frameworks/node-server.md +0 -0
  14. /package/runtime/prompts/{coding-preflight → okstra-coding-preflight}/languages/java.md +0 -0
  15. /package/runtime/prompts/{coding-preflight → okstra-coding-preflight}/languages/javascript-typescript.md +0 -0
  16. /package/runtime/prompts/{coding-preflight → okstra-coding-preflight}/languages/kotlin.md +0 -0
  17. /package/runtime/prompts/{coding-preflight → okstra-coding-preflight}/languages/python.md +0 -0
  18. /package/runtime/prompts/{coding-preflight → okstra-coding-preflight}/languages/rust.md +0 -0
  19. /package/runtime/prompts/{coding-preflight → okstra-coding-preflight}/languages/sql.md +0 -0
  20. /package/runtime/prompts/{coding-preflight → okstra-coding-preflight}/overview.md +0 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "okstra",
3
- "version": "0.83.0",
3
+ "version": "0.84.0",
4
4
  "description": "Multi-agent cross-verification orchestrator runtime + Claude Code skills.",
5
5
  "license": "MIT",
6
6
  "author": "devonshin",
@@ -1,5 +1,5 @@
1
1
  {
2
- "package": "0.83.0",
3
- "builtAt": "2026-06-15T16:01:38.767Z",
2
+ "package": "0.84.0",
3
+ "builtAt": "2026-06-15T18:39:25.947Z",
4
4
  "repoRoot": "/home/runner/work/okstra/okstra"
5
5
  }
@@ -0,0 +1,79 @@
1
+ #!/usr/bin/env bash
2
+ # PreToolUse hook for okstra-coding-preflight.
3
+ #
4
+ # Inspects the target file path of a Write/Edit/MultiEdit/NotebookEdit
5
+ # call. If the extension matches a language covered by this skill,
6
+ # emits a `hookSpecificOutput.additionalContext` JSON payload that
7
+ # reminds the agent to invoke the skill before writing.
8
+ #
9
+ # Fires at most once per session per language (marker file).
10
+ # Exits 0 in every case — never blocks the tool call.
11
+
12
+ set -euo pipefail
13
+
14
+ input="$(cat)"
15
+
16
+ tool_name="$(printf '%s' "$input" | jq -r '.tool_name // empty')"
17
+ case "$tool_name" in
18
+ Write|Edit|MultiEdit|NotebookEdit) ;;
19
+ *) exit 0 ;;
20
+ esac
21
+
22
+ file_path="$(printf '%s' "$input" \
23
+ | jq -r '.tool_input.file_path // .tool_input.notebook_path // empty')"
24
+ [[ -z "$file_path" ]] && exit 0
25
+
26
+ lang=""
27
+ ref=""
28
+ extra_hint=""
29
+ case "$file_path" in
30
+ *.java)
31
+ lang="Java"
32
+ ref="languages/java.md"
33
+ ;;
34
+ *.kt|*.kts)
35
+ lang="Kotlin"
36
+ ref="languages/kotlin.md"
37
+ ;;
38
+ *.ts|*.tsx|*.js|*.jsx|*.mjs|*.cjs)
39
+ lang="JavaScript-TypeScript"
40
+ ref="languages/javascript-typescript.md"
41
+ extra_hint=" If this file is part of a Node.js server (Express/Fastify/Nest/etc.), also read languages/nodejs.md."
42
+ ;;
43
+ *.py)
44
+ lang="Python"
45
+ ref="languages/python.md"
46
+ ;;
47
+ *.sql)
48
+ lang="SQL"
49
+ ref="languages/sql.md"
50
+ ;;
51
+ *.rs)
52
+ lang="Rust"
53
+ ref="languages/rust.md"
54
+ ;;
55
+ *)
56
+ exit 0
57
+ ;;
58
+ esac
59
+
60
+ session_id="$(printf '%s' "$input" | jq -r '.session_id // "no-session"')"
61
+ marker_dir="${TMPDIR:-/tmp}/mcbsc"
62
+ mkdir -p "$marker_dir"
63
+ marker="${marker_dir}/${session_id}_${lang}"
64
+
65
+ if [[ -f "$marker" ]]; then
66
+ exit 0
67
+ fi
68
+
69
+ : > "$marker"
70
+
71
+ skill_root="$HOME/.okstra/prompts/okstra-coding-preflight"
72
+ msg="[okstra-coding-preflight] About to edit a ${lang} file (${file_path}). Before writing, you MUST Read ${skill_root}/${ref} plus ${skill_root}/clean-code.md (the skill is user-invocable:false — read the files directly).${extra_hint} If the project uses ports-and-adapters (domain/ + ports/ + adapters/, *.port.* files), also Read ${skill_root}/architecture/hexagonal.md. (Fires once per session per language.)"
73
+
74
+ jq -nc \
75
+ --arg event "PreToolUse" \
76
+ --arg ctx "$msg" \
77
+ '{hookSpecificOutput: {hookEventName: $event, additionalContext: $ctx}}'
78
+
79
+ exit 0
@@ -79,6 +79,7 @@ from .session import (
79
79
  from .pr_template import PrTemplateError, resolve_pr_template_path
80
80
  from .workers import (
81
81
  normalize_workers,
82
+ resolve_optional_workers,
82
83
  resolve_profile_workers,
83
84
  validate_workers_against_profile,
84
85
  )
@@ -1402,10 +1403,11 @@ def _resolve_roster(inp: PrepareInputs, profile_file: Path) -> tuple[list[str],
1402
1403
  if inp.task_type == "release-handoff":
1403
1404
  return [], ""
1404
1405
  profile_workers = resolve_profile_workers(profile_file)
1406
+ optional_workers = resolve_optional_workers(profile_file)
1405
1407
  profile_workers_csv = ",".join(profile_workers)
1406
1408
  workers = normalize_workers(inp.workers_override or profile_workers_csv)
1407
1409
  if inp.workers_override.strip():
1408
- validate_workers_against_profile(workers, profile_workers)
1410
+ validate_workers_against_profile(workers, profile_workers, optional_workers)
1409
1411
  if not workers:
1410
1412
  raise PrepareError(f"no workers resolved for profile: {inp.task_type}")
1411
1413
  return workers, ",".join(workers)
@@ -145,7 +145,7 @@ If the user picked `Reporter input`, proceed to Step 1.A's sub-flow below — th
145
145
  - **Label**: "Source of this brief?"
146
146
  - **Options** (single-select):
147
147
  1. `File` — path to an existing markdown / txt / issue-export file
148
- 2. `Issue tracker ticket` — Linear / Jira / GitHub Issue / Notion key or URL
148
+ 2. `Issue tracker ticket` — Linear URL/ Jira URL/ GitHub Issue URL/ Notion key or URL
149
149
  3. `Link URL` — general web page, blog, spec doc, design doc, etc.
150
150
  4. `User input` — synthesize from the current conversation context, or
151
151
  accept a short free-text from the user
@@ -678,7 +678,7 @@ Use the same template per brief file. In tracker mode producing a parent
678
678
  plus N children, you write **N+1 files** (each carries only its own Source
679
679
  Material).
680
680
 
681
- [`templates/reports/brief.template.md`](../../templates/reports/brief.template.md) is the byte-for-byte SSOT for the brief shape — frontmatter keys, top-header blockquote, section ordering, and inline `<!-- author guidance -->` HTML comments. Render that file with the per-brief values substituted; leave any section's body as `_(none)_` rather than fabricating content. Preserve the template's HTML comments verbatim (they are part of the contract) but do NOT promote them to body prose.
681
+ The installed template `~/.okstra/templates/reports/brief.template.md` is the byte-for-byte SSOT for the brief shape — frontmatter keys, top-header blockquote, section ordering, and inline `<!-- author guidance -->` HTML comments. Render that file with the per-brief values substituted; leave any section's body as `_(none)_` rather than fabricating content. Preserve the template's HTML comments verbatim (they are part of the contract) but do NOT promote them to body prose.
682
682
 
683
683
  **Variant note:** The template file is the canonical reporter-input shape. For the codebase-scan variant (`scope: codebase` in frontmatter), OMIT the `## Source Material` and `## Problem / Symptom` headings entirely — do NOT include them with `_(none)_` bodies. Instead, KEEP the `## Scan Scope` and `## Priority Lenses` sections (already present in the template file, marked with HTML comments). The remaining sections (Context / Desired Outcome / Constraints / Related Artifacts / Open Questions / Reporter Confirmations / Augmentation) apply to both variants.
684
684
 
@@ -710,6 +710,22 @@ Material).
710
710
  frontmatter.
711
711
  - `ticket-id` is populated only for tracker sources; leave it as an empty
712
712
  string otherwise.
713
+ - `source-type` records the input origin (`file | linear | jira | github |
714
+ notion | url | user-input`).
715
+ - `task-group` echoes the Step 2a value.
716
+ - `created` is the brief's creation date (`YYYY-MM-DD`).
717
+ - `generator: okstra-brief` is a fixed value.
718
+ - `reporter-confirmations` is set by Step 6.5 (`complete | partial | pending |
719
+ skipped`).
720
+ - `scope` is `codebase` for the codebase-scan variant; omit it (⇒
721
+ `reporter-input`) for the reporter-input variant.
722
+
723
+ The required-key set (`type`, `brief-id`, `parent-id`, `ticket-id`,
724
+ `source-type`, `task-group`, `depth`, `created`, `generator`,
725
+ `reporter-confirmations`) and every consistency rule above are enforced by
726
+ `~/.okstra/lib/validators/validate-brief.py` — run in
727
+ Step 6.6. The byte-for-byte field shape remains the job of
728
+ `~/.okstra/templates/reports/brief.template.md`.
713
729
 
714
730
  Echo the file path back on one line. Show the rendered brief to the user
715
731
  inline and ask:
@@ -750,8 +766,17 @@ cannot resolve:
750
766
  - `intent-check:` (auto-mirrored from `intent-inference` augmentations)
751
767
  - `conversion-block:` (translation failed in Step 3c)
752
768
 
753
- If the resulting list is empty, set `reporter-confirmations: complete` in
754
- the brief's frontmatter and skip the rest of this step.
769
+ **Re-run reseed (rebuild from disk).** A prior run may have left this brief at
770
+ `reporter-confirmations: partial` with some rows already answered. Before
771
+ building the pending list, exclude every `intent-check:` / `conversion-block:`
772
+ row that already carries a `[CONFIRMED <date> → RC-N]` marker — those were
773
+ resolved in an earlier run and their answers already live under `## Reporter
774
+ Confirmations`. Only the unmarked rows are pending this run. This mirrors the
775
+ visited-set rebuild rule in Step 1b: confirmation state lives on disk, not in
776
+ memory.
777
+
778
+ If the resulting pending list is empty, set `reporter-confirmations: complete`
779
+ in the brief's frontmatter and skip the rest of this step.
755
780
 
756
781
  ### 6.5b. Ask once: collect now or later
757
782
 
@@ -839,6 +864,37 @@ rows (`complete` / `partial` with remainder in the same file) or an
839
864
  explicit `skipped` signal that downstream phases consume per the
840
865
  "Brief consumption" addendum in `_common-contract.md`.
841
866
 
867
+ ## Step 6.6: Self-validate before hand-off
868
+
869
+ After every brief is written (Step 5) and its `reporter-confirmations` state
870
+ is set (Step 6.5), and **before** printing the Step 7 hand-off message, you
871
+ MUST validate every brief produced this run. The validator enforces the
872
+ labelled-handoff contract — required frontmatter keys, Augmentation labels,
873
+ `intent-inference ↔ intent-check:` auto-mirroring, `parent-id`/`depth`
874
+ consistency, `reporter-confirmations` markers, and the codebase-scan
875
+ `scope`/`Scan Scope`/`Priority Lenses` rules.
876
+
877
+ 1. Run the validator over the briefs directory:
878
+
879
+ ```bash
880
+ ~/.okstra/lib/validators/validate-brief.sh "<PROJECT_ROOT>/.okstra/briefs" --briefs-root "<PROJECT_ROOT>/.okstra/briefs"
881
+ ```
882
+
883
+ If you are working from a repo checkout instead of an install, the dev
884
+ copy is at `validators/validate-brief.sh`.
885
+
886
+ 2. If it exits non-zero, **fix the offending brief(s) and re-run**. Do NOT
887
+ print the Step 7 message until the validator passes.
888
+
889
+ 3. If neither validator copy is present, fall back to a manual checklist:
890
+ confirm each brief has all ten required frontmatter keys (`type`,
891
+ `brief-id`, `parent-id`, `ticket-id`, `source-type`, `task-group`,
892
+ `depth`, `created`, `generator`, `reporter-confirmations`); every
893
+ Augmentation entry carries one of the four labels; every `intent-inference`
894
+ has a paired `intent-check:` row; `parent-id` is `self` at depth 0 and a
895
+ real parent id below; and for `scope: codebase` briefs, `Scan Scope` is
896
+ non-empty and `Priority Lenses` lists 1–4 whitelist lenses.
897
+
842
898
  ## Step 7: Hand off
843
899
 
844
900
  Single brief:
@@ -866,13 +922,10 @@ started.
866
922
 
867
923
  ## Output Rules
868
924
 
869
- - All okstra-generated artifacts live under
870
- `<PROJECT_ROOT>/.okstra/`. This skill's brief files go
871
- under `.okstra/briefs/`; its glossary writes go to
872
- `.okstra/glossary.md` (Step 4.5 approval flow).
873
- - Paths outside `<PROJECT_ROOT>/.okstra/**` are read-only
874
- source material only when explicitly cited by the reporter. This skill
875
- never writes outside okstra's subtree.
925
+ - **Artifact-home** (see the "Artifact-home rule" section above): every write
926
+ stays under `<PROJECT_ROOT>/.okstra/` briefs in `.okstra/briefs/`, glossary
927
+ in `.okstra/glossary.md` (Step 4.5). Paths outside that subtree are read-only
928
+ source material, and only when the reporter cited them.
876
929
  - **Verbatim source**: never paraphrase, summarize, restructure, or reorder
877
930
  the Source Material section. Only format conversion (ADF → MD, HTML → MD)
878
931
  is allowed, and the conversion must be annotated in the `format:` meta.
@@ -910,3 +963,4 @@ started.
910
963
  | URL fetch fails or 401 / login wall | intranet, paywall, JS-rendered page | Ask the user to paste the core body. Never guess. |
911
964
  | Tracker body is in non-MD format (ADF / HTML) | Jira description, Notion blocks | Convert format only, preserve semantics. Annotate the conversion in the `format:` meta. |
912
965
  | Brief comes out skeletal after synthesis | source too thin AND user picked `User input` with little context | Switch to inline input mode and ask one or two targeted questions. |
966
+ | `validate-brief` exits non-zero in Step 6.6 | missing frontmatter key, unlabelled augmentation, broken auto-mirroring, or codebase-scan lens out of whitelist | Read the validator's per-row message, fix the cited brief, re-run. Never print the Step 7 hand-off until it passes. |
@@ -360,7 +360,7 @@ If a run never reached Phase 7, its `team-state` lacks `durationMs`. Mark such r
360
360
  3. Multiple matches → list candidates (`taskKey`, `taskType`, `updatedAt`) and ask the user to pick.
361
361
  4. Read `historyTimelinePath` from the chosen entry.
362
362
 
363
- If `task-catalog.json` is missing: `No okstra history found. Run scripts/okstra.sh first.`
363
+ If `task-catalog.json` is missing: `No okstra history found. Run /okstra-run first.`
364
364
 
365
365
  ### time.2 — Walk runs and collect durations
366
366
 
@@ -236,7 +236,7 @@ The validator rejects any of these patterns.
236
236
 
237
237
  ## Section Contract (required)
238
238
 
239
- The canonical template lives at `templates/reports/schedule.template.md`. **Read it before writing the schedule** and follow it byte-for-byte for headings.
239
+ The canonical template lives at `~/.okstra/templates/reports/schedule.template.md`. **Read it before writing the schedule** and follow it byte-for-byte for headings.
240
240
 
241
241
  ### MUST — exact ordered heading list
242
242
 
@@ -441,14 +441,14 @@ After writing the file and before printing the completion message in Step 7, you
441
441
  1. **Re-read the file** you just wrote.
442
442
  2. **Run the validator**:
443
443
  ```bash
444
- python3 validators/validate-schedule.py <output-path>
444
+ python3 ~/.okstra/lib/validators/validate-schedule.py <output-path>
445
445
  ```
446
446
  3. If the validator exits non-zero, **fix the file and re-validate**. Do not print the completion message until the validator passes.
447
- 4. If `validators/validate-schedule.py` is not present in the repo, fall back to a manual checklist: confirm each of the 11 mandatory headings from `### MUST — exact ordered heading list` (above) appears in the file with that exact spelling, the title ends with `— Work Schedule`, and the metadata `> Generated:` block is present.
447
+ 4. If `~/.okstra/lib/validators/validate-schedule.py` is not present, fall back to a manual checklist: confirm each of the 11 mandatory headings from `### MUST — exact ordered heading list` (above) appears in the file with that exact spelling, the title ends with `— Work Schedule`, and the metadata `> Generated:` block is present.
448
448
 
449
449
  ## Schedule Document Template
450
450
 
451
- [`templates/reports/schedule.template.md`](../../templates/reports/schedule.template.md) is the byte-for-byte SSOT for section ordering, heading spelling, table columns, and per-section HTML-comment guidance (Dependency Graph Shape A/B, Gantt rules, optional Glossary gate). Do not omit sections; if data is unavailable, render the section with `_없음_` rather than skipping it.
451
+ The installed template `~/.okstra/templates/reports/schedule.template.md` is the byte-for-byte SSOT for section ordering, heading spelling, table columns, and per-section HTML-comment guidance (Dependency Graph Shape A/B, Gantt rules, optional Glossary gate). Do not omit sections; if data is unavailable, render the section with `_없음_` rather than skipping it.
452
452
 
453
453
  The two rules below complement the template — they encode COMPUTATION that the template scaffold cannot carry inline.
454
454
 
@@ -250,8 +250,8 @@ back it up as `.bak.<timestamp>` rather than overwriting silently.
250
250
  ## Step 4.8 (optional, opt-in): register a project PR body template
251
251
 
252
252
  `release-handoff` fills the PR body from a template. By default it uses the
253
- bundled skill template at
254
- `~/.claude/skills/templates/prd/pr-body.template.md`. Most projects
253
+ installed template at
254
+ `~/.okstra/templates/prd/pr-body.template.md`. Most projects
255
255
  want their own — e.g. the repo's `.github/PULL_REQUEST_TEMPLATE.md` — to
256
256
  keep PRs consistent with what the team already merges manually.
257
257
 
@@ -14,140 +14,31 @@
14
14
  "WebFetch",
15
15
  "WebSearch",
16
16
  "TodoWrite",
17
-
18
- "Bash(pwd)",
19
- "Bash(cd)",
20
- "Bash(cd:*)",
21
-
22
- "Bash(ls)",
23
- "Bash(ls:*)",
24
- "Bash(cat:*)",
25
- "Bash(head:*)",
26
- "Bash(tail:*)",
27
- "Bash(wc:*)",
28
-
29
- "Bash(git)",
30
- "Bash(git:*)",
31
- "Bash(gh)",
32
- "Bash(gh:*)",
33
-
34
- "Bash(rg:*)",
35
- "Bash(grep:*)",
36
- "Bash(find:*)",
37
- "Bash(diff:*)",
38
-
39
- "Bash(jq:*)",
40
- "Bash(yq:*)",
41
- "Bash(awk:*)",
42
- "Bash(sed:*)",
43
- "Bash(tr:*)",
44
- "Bash(sort:*)",
45
- "Bash(uniq:*)",
46
- "Bash(xargs:*)",
47
-
48
- "Bash(mkdir:*)",
49
- "Bash(cp:*)",
50
- "Bash(mv:*)",
51
- "Bash(rm:*)",
52
- "Bash(chmod:*)",
53
-
54
- "Bash(printf:*)",
55
- "Bash(echo)",
56
- "Bash(echo:*)",
57
- "Bash(export:*)",
58
- "Bash(env)",
59
- "Bash(env:*)",
60
- "Bash(test:*)",
61
- "Bash(true)",
62
- "Bash(true:*)",
63
- "Bash(false)",
64
- "Bash(false:*)",
65
-
66
- "Bash(bash:*)",
67
- "Bash(sh:*)",
68
- "Bash(eval:*)",
69
-
70
- "Bash(python)",
71
- "Bash(python:*)",
72
- "Bash(python3)",
73
- "Bash(python3:*)",
74
- "Bash(pip)",
75
- "Bash(pip:*)",
76
- "Bash(pip3)",
77
- "Bash(pip3:*)",
78
- "Bash(uv:*)",
79
- "Bash(poetry:*)",
80
- "Bash(pytest:*)",
81
-
82
- "Bash(node)",
83
- "Bash(node:*)",
84
- "Bash(npm)",
85
- "Bash(npm:*)",
86
- "Bash(npx)",
87
- "Bash(npx:*)",
88
- "Bash(pnpm)",
89
- "Bash(pnpm:*)",
90
- "Bash(yarn)",
91
- "Bash(yarn:*)",
92
- "Bash(tsc)",
93
- "Bash(tsc:*)",
94
-
95
- "Bash(cargo)",
96
- "Bash(cargo:*)",
97
- "Bash(rustc)",
98
- "Bash(rustc:*)",
99
-
100
- "Bash(go)",
101
- "Bash(go:*)",
102
- "Bash(java)",
103
- "Bash(java:*)",
104
- "Bash(javac)",
105
- "Bash(javac:*)",
106
- "Bash(mvn)",
107
- "Bash(mvn:*)",
108
- "Bash(gradle)",
109
- "Bash(gradle:*)",
110
- "Bash(./gradlew)",
111
- "Bash(./gradlew:*)",
112
- "Bash(make)",
113
- "Bash(make:*)",
114
-
115
- "Bash(docker)",
116
- "Bash(docker:*)",
117
- "Bash(docker compose)",
118
- "Bash(docker compose:*)",
119
- "Bash(docker-compose)",
120
- "Bash(docker-compose:*)",
121
- "Bash(kubectl)",
122
- "Bash(kubectl:*)",
123
- "Bash(helm)",
124
- "Bash(helm:*)",
125
-
126
- "Bash(curl:*)",
127
- "Bash(wget:*)",
128
-
129
- "Bash(codex)",
130
- "Bash(codex:*)",
131
- "Bash(codex exec:*)",
132
17
  "Bash(okstra)",
133
18
  "Bash(okstra:*)",
134
19
  "Bash(npx okstra@latest:*)",
135
20
  "Bash(npx -y okstra@latest:*)",
136
21
  "Bash($HOME/.okstra/bin/:*)",
137
- "Bash(STATE_FILE=:*)",
138
- "Bash(ROOT=:*)",
139
-
22
+ "Bash(codex)",
23
+ "Bash(codex:*)",
140
24
  "Bash(gemini)",
141
25
  "Bash(gemini:*)",
142
-
143
26
  "Bash(claude)",
144
- "Bash(claude:*)",
145
-
146
- "mcp__test-context7__resolve-library-id",
147
- "mcp__test-context7__query-docs"
27
+ "Bash(claude:*)"
148
28
  ]
149
29
  },
150
30
  "hooks": {
31
+ "PreToolUse": [
32
+ {
33
+ "matcher": "Write|Edit|MultiEdit|NotebookEdit",
34
+ "hooks": [
35
+ {
36
+ "type": "command",
37
+ "command": "bash \"$HOME/.okstra/prompts/okstra-coding-preflight/scripts/preedit-check.sh\""
38
+ }
39
+ ]
40
+ }
41
+ ],
151
42
  "SessionEnd": [
152
43
  {
153
44
  "hooks": [
@@ -25,7 +25,13 @@ Checks performed per brief file:
25
25
  own `brief-id`.
26
26
  9. `reporter-confirmations` consistency: when `complete`, every
27
27
  `intent-check:` and `conversion-block:` row in Open Questions MUST
28
- carry a `[CONFIRMED YYYY-MM-DD → RC-N]` marker.
28
+ carry a `[CONFIRMED YYYY-MM-DD → RC-N]` marker; when `partial`, at
29
+ least one such row MUST carry the marker (use `skipped` when nothing
30
+ was answered).
31
+ 10. `scope` is one of {reporter-input, codebase} (absent ⇒ reporter-input).
32
+ 11. codebase-scan variant (`scope: codebase`): the Scan Scope section is
33
+ non-empty and Priority Lenses lists 1–4 values from the lens whitelist
34
+ (`scripts/okstra_ctl/improvement_lenses.py` SSOT).
29
35
 
30
36
  Exit code 0 on PASS, 1 on FAIL.
31
37
  """
@@ -38,6 +44,19 @@ import sys
38
44
  from pathlib import Path
39
45
  from typing import Iterable
40
46
 
47
+ # scripts/ (repo) or python/ (installed under ~/.okstra/lib) is not a package;
48
+ # insert whichever exists so okstra_ctl is importable for the lens whitelist.
49
+ _VALIDATORS_DIR = Path(__file__).resolve().parent
50
+ for _ssot_dir in (_VALIDATORS_DIR.parent / "scripts", _VALIDATORS_DIR.parent / "python"):
51
+ if _ssot_dir.is_dir() and str(_ssot_dir) not in sys.path:
52
+ sys.path.insert(0, str(_ssot_dir))
53
+
54
+ from okstra_ctl.improvement_lenses import (
55
+ LENSES,
56
+ MAX_PRIORITY_LENSES,
57
+ MIN_PRIORITY_LENSES,
58
+ )
59
+
41
60
  REQUIRED_FRONTMATTER_KEYS = {
42
61
  "type",
43
62
  "brief-id",
@@ -68,6 +87,8 @@ AUGMENTATION_LABELS = {
68
87
 
69
88
  REPORTER_CONFIRMATION_VALUES = {"complete", "partial", "pending", "skipped"}
70
89
 
90
+ SCOPE_VALUES = {"reporter-input", "codebase"}
91
+
71
92
 
72
93
  def parse_frontmatter(text: str) -> tuple[dict[str, str], int]:
73
94
  """Return (frontmatter dict, line after closing `---`)."""
@@ -173,6 +194,68 @@ def parse_augmentation_label(entry: str) -> tuple[str | None, str]:
173
194
  return None, stripped
174
195
 
175
196
 
197
+ def meaningful_bullets(text: str, heading: str) -> list[str]:
198
+ """Real `- ` bullets under a heading, excluding placeholders/template scaffold."""
199
+ body = section_body(text, heading)
200
+ out: list[str] = []
201
+ for line in body.splitlines():
202
+ stripped = line.strip()
203
+ if not stripped.startswith("- "):
204
+ continue
205
+ content = stripped[2:].strip()
206
+ if is_placeholder(content) or is_template_example(content):
207
+ continue
208
+ out.append(content.strip("`"))
209
+ return out
210
+
211
+
212
+ def priority_lens_values(text: str) -> list[str]:
213
+ """Lens tokens from the `## Priority Lenses` bullets (`- <lens>: <rationale>`)."""
214
+ return [
215
+ bullet.split(":", 1)[0].strip()
216
+ for bullet in meaningful_bullets(text, "Priority Lenses")
217
+ ]
218
+
219
+
220
+ def check_codebase_scope(text: str, errors: list[str]) -> None:
221
+ """codebase-scan variant: Scan Scope non-empty, Priority Lenses ⊆ whitelist."""
222
+ if not meaningful_bullets(text, "Scan Scope"):
223
+ errors.append("scope is 'codebase' but the Scan Scope section has no entries")
224
+ lenses = priority_lens_values(text)
225
+ if not (MIN_PRIORITY_LENSES <= len(lenses) <= MAX_PRIORITY_LENSES):
226
+ errors.append(
227
+ f"Priority Lenses must list {MIN_PRIORITY_LENSES}–{MAX_PRIORITY_LENSES} "
228
+ f"lens(es), found {len(lenses)}"
229
+ )
230
+ unknown = [lens for lens in lenses if lens not in LENSES]
231
+ if unknown:
232
+ errors.append(
233
+ f"Priority Lenses contains values outside the lens whitelist: {unknown} "
234
+ f"(allowed: {sorted(LENSES)})"
235
+ )
236
+
237
+
238
+ def check_reporter_confirmations(
239
+ rc_status: str | None, reporter_rows: list[str], errors: list[str]
240
+ ) -> None:
241
+ """`complete` ⇒ every reporter row confirmed; `partial` ⇒ at least one."""
242
+ confirmed = [row for row in reporter_rows if "[CONFIRMED" in row]
243
+ if rc_status == "complete":
244
+ unconfirmed = [row for row in reporter_rows if "[CONFIRMED" not in row]
245
+ if unconfirmed:
246
+ errors.append(
247
+ f"reporter-confirmations is 'complete' but {len(unconfirmed)} "
248
+ f"intent-check:/conversion-block: row(s) lack a [CONFIRMED …] "
249
+ f"marker (e.g. {unconfirmed[0]!r})"
250
+ )
251
+ elif rc_status == "partial" and reporter_rows and not confirmed:
252
+ errors.append(
253
+ "reporter-confirmations is 'partial' but no intent-check:/"
254
+ "conversion-block: row carries a [CONFIRMED …] marker "
255
+ "(use 'skipped' when nothing was answered)"
256
+ )
257
+
258
+
176
259
  def validate_brief(path: Path, briefs_root: Path) -> list[str]:
177
260
  text = path.read_text(encoding="utf-8")
178
261
  errors: list[str] = []
@@ -202,6 +285,15 @@ def validate_brief(path: Path, briefs_root: Path) -> list[str]:
202
285
  f"{fm.get('reporter-confirmations')!r}"
203
286
  )
204
287
 
288
+ scope = fm.get("scope", "reporter-input")
289
+ if scope not in SCOPE_VALUES:
290
+ errors.append(
291
+ f"frontmatter scope must be one of {sorted(SCOPE_VALUES)} "
292
+ f"(or absent ⇒ reporter-input), got {scope!r}"
293
+ )
294
+ if scope == "codebase":
295
+ check_codebase_scope(text, errors)
296
+
205
297
  # 2. brief-id matches filename stem
206
298
  stem = path.stem
207
299
  if fm.get("brief-id") and fm["brief-id"] != stem:
@@ -306,21 +398,12 @@ def validate_brief(path: Path, briefs_root: Path) -> list[str]:
306
398
  f"({brief_id!r})"
307
399
  )
308
400
 
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
- )
401
+ # 9. reporter-confirmations consistency (complete / partial)
402
+ check_reporter_confirmations(
403
+ fm.get("reporter-confirmations"),
404
+ intent_check_rows + conversion_block_rows,
405
+ errors,
406
+ )
324
407
 
325
408
  return errors
326
409