okstra 0.140.0 → 0.141.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 (37) hide show
  1. package/README.md +1 -1
  2. package/docs/architecture.md +1 -1
  3. package/docs/cli.md +1 -1
  4. package/package.json +1 -1
  5. package/runtime/BUILD.json +2 -2
  6. package/runtime/agents/workers/report-writer-worker.md +4 -2
  7. package/runtime/prompts/launch.template.md +7 -5
  8. package/runtime/prompts/lead/convergence.md +22 -0
  9. package/runtime/prompts/lead/plan-body-verification.md +45 -6
  10. package/runtime/prompts/lead/report-writer.md +2 -0
  11. package/runtime/prompts/profiles/error-analysis.md +1 -0
  12. package/runtime/prompts/profiles/final-verification.md +1 -1
  13. package/runtime/prompts/profiles/implementation-planning.md +6 -4
  14. package/runtime/prompts/profiles/improvement-discovery.md +2 -1
  15. package/runtime/prompts/profiles/requirements-discovery.md +2 -1
  16. package/runtime/python/okstra_ctl/build_tools.py +95 -0
  17. package/runtime/python/okstra_ctl/incremental_scope.py +95 -0
  18. package/runtime/python/okstra_ctl/render.py +26 -11
  19. package/runtime/python/okstra_ctl/render_final_report.py +24 -1
  20. package/runtime/python/okstra_ctl/rollup.py +49 -0
  21. package/runtime/python/okstra_ctl/scope_provenance.py +98 -1
  22. package/runtime/python/okstra_ctl/stage_citations.py +47 -0
  23. package/runtime/python/okstra_ctl/workflow.py +7 -4
  24. package/runtime/schemas/final-report-v1.0.schema.json +60 -1
  25. package/runtime/skills/okstra-brief-gen/SKILL.md +38 -11
  26. package/runtime/skills/okstra-rollup/SKILL.md +3 -2
  27. package/runtime/templates/reports/brief.template.md +42 -11
  28. package/runtime/templates/reports/fan-out-unit.template.md +9 -4
  29. package/runtime/templates/reports/final-report.template.md +20 -0
  30. package/runtime/templates/reports/final-verification-input.template.md +4 -2
  31. package/runtime/templates/reports/i18n/en.json +11 -4
  32. package/runtime/templates/reports/i18n/ko.json +11 -4
  33. package/runtime/templates/reports/improvement-discovery-input.template.md +2 -2
  34. package/runtime/validators/validate-brief.py +171 -26
  35. package/runtime/validators/validate-run.py +450 -78
  36. package/runtime/validators/validate_fanout.py +19 -10
  37. package/runtime/validators/validate_improvement_report.py +17 -4
@@ -10,14 +10,28 @@ from __future__ import annotations
10
10
 
11
11
  import argparse
12
12
  import json
13
+ import re
13
14
  import sys
14
15
  from dataclasses import asdict, dataclass
15
16
  from pathlib import Path
16
17
 
18
+ from okstra_ctl.stage_citations import cited_stage_numbers
17
19
  from okstra_ctl.stage_targets import downstream_stage_closure
18
20
 
19
21
  CUTOFF_RATIO = 0.5
20
22
 
23
+ # Prefix that marks a full decision the lead *chose* rather than fell back to.
24
+ # Both cases return `mode: full`, and without this the two are one string —
25
+ # which is what made a re-run's "no impacted stages resolved" unreadable as
26
+ # either a structural judgement or a mapping the lead never made.
27
+ DECLARED_FULL_PREFIX = "declared structural change:"
28
+
29
+ # `P-Step-<stage>.<step>` and `P-Prep-S<stage>-<kind>` carry their stage in the
30
+ # id itself. Every other prefix (`P-Opt`, `P-Dep`, `P-Val`, `P-Rb`, `P-Req`) is
31
+ # numbered by position in its own array, so its stage is only recoverable from
32
+ # the prose the planner wrote.
33
+ _STRUCTURAL_STAGE_IN_ID_RE = re.compile(r"^P-(?:Step-(\d+)\.\d+|Prep-S(\d+)-)")
34
+
21
35
 
22
36
  @dataclass
23
37
  class IncrementalDecision:
@@ -73,6 +87,60 @@ def design_prep_impacted_stages(data: dict, item_ids: set[str]) -> set[int]:
73
87
  return impacted
74
88
 
75
89
 
90
+ def _plan_item_stages(item: dict) -> set[int]:
91
+ structural = _STRUCTURAL_STAGE_IN_ID_RE.match(str(item.get("id") or ""))
92
+ if structural:
93
+ return {int(structural.group(1) or structural.group(2))}
94
+ return cited_stage_numbers(str(item.get("subject") or ""))
95
+
96
+
97
+ def _stages_blocked_on(coverage: object, clarification_id: str) -> set[int]:
98
+ blocked = f"blocked {clarification_id}"
99
+ stages: set[int] = set()
100
+ for row in coverage if isinstance(coverage, list) else []:
101
+ if not isinstance(row, dict):
102
+ continue
103
+ if str(row.get("status") or "").strip() != blocked:
104
+ continue
105
+ stages |= cited_stage_numbers(str(row.get("coveredBy") or ""))
106
+ return stages
107
+
108
+
109
+ def clarification_impacted_stages(
110
+ data: dict, clarification_ids: set[str]
111
+ ) -> set[int]:
112
+ """Stage Map stages the answered clarifications touch, read off the prior run.
113
+
114
+ Two links the prior report already recorded: the `P-*` plan item that blocked
115
+ on the clarification, and the requirement-coverage row blocked on the same
116
+ id. Resolving them here removes the discretionary mapping step that made the
117
+ lead fall back to full even for an answer that changed nothing.
118
+
119
+ Raises when any id resolves to no stage — a partially-resolved set would
120
+ narrow the re-run past an answer whose blast radius nobody established.
121
+ """
122
+ planning = data.get("implementationPlanning")
123
+ if not isinstance(planning, dict):
124
+ raise ValueError("implementationPlanning is missing")
125
+ verification = planning.get("planBodyVerification")
126
+ plan_items = (verification or {}).get("planItems") if isinstance(verification, dict) else []
127
+ coverage = planning.get("requirementCoverage")
128
+
129
+ impacted: set[int] = set()
130
+ for clarification_id in sorted(clarification_ids):
131
+ stages = _stages_blocked_on(coverage, clarification_id)
132
+ for item in plan_items if isinstance(plan_items, list) else []:
133
+ if isinstance(item, dict) and item.get("clarificationId") == clarification_id:
134
+ stages |= _plan_item_stages(item)
135
+ if not stages:
136
+ raise ValueError(
137
+ f"answered clarification {clarification_id} traces to no stage "
138
+ "(no plan item or coverage row cites one)"
139
+ )
140
+ impacted |= stages
141
+ return impacted
142
+
143
+
76
144
  def decide_scope(
77
145
  *,
78
146
  stages: list[tuple[int, list[int]]],
@@ -107,14 +175,41 @@ def main(argv: list[str]) -> int:
107
175
  ap.add_argument("--prev-base-sha", required=True)
108
176
  ap.add_argument("--impacted", default="", help="comma-separated impacted stage numbers")
109
177
  ap.add_argument("--prep-items", default="", help="comma-separated changed PREP item IDs")
178
+ ap.add_argument(
179
+ "--answered-clarifications",
180
+ default="",
181
+ help="comma-separated C-NNN ids answered since the prior run; their "
182
+ "stages are resolved from that run's plan-item and coverage links",
183
+ )
184
+ ap.add_argument(
185
+ "--full-reason",
186
+ default="",
187
+ help="declare that an answer changes the selected option, Stage Map, or "
188
+ "recommended approach. Forces full and records the judgement, so it "
189
+ "is distinguishable from a re-run that simply resolved no stage",
190
+ )
110
191
  args = ap.parse_args(argv)
111
192
 
193
+ declared = args.full_reason.strip()
194
+ if declared:
195
+ # The lead's structural judgement is exactly what the back-trace cannot
196
+ # make, so a successful trace must not override it.
197
+ print(json.dumps(asdict(IncrementalDecision(
198
+ "full", [], [], f"{DECLARED_FULL_PREFIX} {declared}",
199
+ )), ensure_ascii=False))
200
+ return 0
201
+
112
202
  try:
113
203
  data = json.loads(Path(args.prev_data).read_text(encoding="utf-8"))
114
204
  stages = parse_stage_graph(data)
115
205
  impacted = {int(t.strip()) for t in args.impacted.split(",") if t.strip()}
116
206
  prep_ids = {t.strip() for t in args.prep_items.split(",") if t.strip()}
117
207
  impacted.update(design_prep_impacted_stages(data, prep_ids))
208
+ answered = {
209
+ t.strip() for t in args.answered_clarifications.split(",") if t.strip()
210
+ }
211
+ if answered:
212
+ impacted.update(clarification_impacted_stages(data, answered))
118
213
  unknown_stages = impacted - {stage for stage, _ in stages}
119
214
  if unknown_stages:
120
215
  unknown = ", ".join(str(stage) for stage in sorted(unknown_stages))
@@ -104,23 +104,38 @@ def _runtime_resolution(ctx: dict) -> dict:
104
104
  return payload if isinstance(payload, dict) else {}
105
105
 
106
106
 
107
+ # The phases a `{% if header.taskType ... %}` block may target: the lifecycle
108
+ # sequence itself, kept as an allowlist rather than `[a-z-]+` so a typo'd phase
109
+ # name fails to match and the block survives the copy — caught by eye instead of
110
+ # silently vanishing from every phase's excerpt. Longest-first so
111
+ # `implementation-planning` is never matched as `implementation`.
112
+ _PHASE_ALT = "|".join(
113
+ re.escape(phase) for phase in sorted(PHASE_SEQUENCE, key=len, reverse=True)
114
+ )
115
+ # Two forms: a single-phase block (`== 'X'`) and a multi-phase one
116
+ # (`in ('X', 'Y')`). The multi form exists because a section can belong to a
117
+ # set of phases — `### 2.3 End State Coverage` is written by the three
118
+ # pre-implementation phases and by no other.
107
119
  _PHASE_BLOCK_RE = re.compile(
108
- r"\{% if header\.taskType == '(implementation-planning|release-handoff|implementation|final-verification)' %\}\n(.*?)\{% endif %\}\n",
120
+ r"\{% if header\.taskType (?:"
121
+ rf"== '(?P<one>{_PHASE_ALT})'"
122
+ rf"|in \((?P<many>'(?:{_PHASE_ALT})'(?:\s*,\s*'(?:{_PHASE_ALT})')*),?\s*\)"
123
+ r") %\}\n(?P<body>.*?)\{% endif %\}\n",
109
124
  re.DOTALL,
110
125
  )
126
+ _PHASE_NAME_RE = re.compile(r"'([a-z-]+)'")
111
127
 
112
128
 
113
129
  def _strip_phase_blocks(text: str, current_phase: str) -> str:
114
130
  """Resolve phase-conditional blocks (`{% if header.taskType == 'X' %}
115
131
  ... {% endif %}`) against *current_phase*.
116
132
 
117
- Blocks whose target equals *current_phase* keep their body (jinja
118
- markers dropped); blocks targeting a different phase are removed
119
- entirely. When *current_phase* is empty or not one of the four
120
- block-targetable phases (e.g. `requirements-discovery`,
121
- `error-analysis`), every block is dropped correct because none of
122
- the `## 5.5` / `5.6` / `5.7` / `5.8` deliverable sections apply
123
- there.
133
+ Blocks whose target set contains *current_phase* keep their body (jinja
134
+ markers dropped); blocks targeting only other phases are removed
135
+ entirely. A phase that no block names keeps none of them — and when
136
+ *current_phase* is empty that is every block. The `## 5.5` / `5.6` /
137
+ `5.7` / `5.8` deliverable sections stay single-phase, so they still
138
+ apply to exactly one task-type each.
124
139
 
125
140
  Observed (fontsninja-classifier-v2 RD run): the raw final-report
126
141
  template copied into instruction-set/final-report-template.md was
@@ -135,9 +150,9 @@ def _strip_phase_blocks(text: str, current_phase: str) -> str:
135
150
  """
136
151
 
137
152
  def repl(m: "re.Match[str]") -> str:
138
- target_phase = m.group(1)
139
- body = m.group(2)
140
- return body if target_phase == current_phase else ""
153
+ one = m.group("one")
154
+ targets = {one} if one else set(_PHASE_NAME_RE.findall(m.group("many")))
155
+ return m.group("body") if current_phase in targets else ""
141
156
 
142
157
  return _PHASE_BLOCK_RE.sub(repl, text)
143
158
 
@@ -577,6 +577,29 @@ def resolve_report_language(data: dict, *, override: str | None) -> str:
577
577
  return candidate
578
578
 
579
579
 
580
+ # Schema-optional top-level arrays, and the value a template may assume when
581
+ # the report omits them. The vendored jinja2 does not resolve a missing
582
+ # top-level name to `Undefined`: `| default([])` passes the sentinel straight
583
+ # through, `| length` raises on it, and both `x` and `not x` evaluate true. So a
584
+ # template cannot test for absence at all, and the value has to arrive filled.
585
+ _OPTIONAL_ARRAY_DEFAULTS = ("endStateCoverage",)
586
+
587
+
588
+ def _with_optional_defaults(data: dict) -> dict:
589
+ """Render context with schema-optional arrays filled in.
590
+
591
+ Keeps the schema field optional — an omitted `endStateCoverage` stays absent
592
+ in data.json, which is what the run validator reads to tell a legacy brief
593
+ apart from a dropped requirement — while giving the template a value it can
594
+ branch on.
595
+ """
596
+ filled = dict(data)
597
+ for key in _OPTIONAL_ARRAY_DEFAULTS:
598
+ if not isinstance(filled.get(key), list):
599
+ filled[key] = []
600
+ return filled
601
+
602
+
580
603
  def render(
581
604
  data: dict,
582
605
  *,
@@ -606,7 +629,7 @@ def render(
606
629
 
607
630
  try:
608
631
  template = env.get_template(template_path.name)
609
- rendered = template.render(**data)
632
+ rendered = template.render(**_with_optional_defaults(data))
610
633
  rendered = _inject_index_and_anchors(rendered, dictionary)
611
634
  return _ventilate_prose(rendered)
612
635
  except I18nError as exc:
@@ -23,6 +23,38 @@ from okstra_project import (
23
23
  )
24
24
 
25
25
 
26
+ _CRITIC_COUNTERS = (
27
+ "gapsProposed",
28
+ "gapsMerged",
29
+ "gapsRejected",
30
+ "gapsUnverified",
31
+ )
32
+
33
+
34
+ def _collect_critic(task_root: Path) -> dict:
35
+ """task 의 모든 run 에서 커버리지 크리틱 결과를 합산.
36
+
37
+ 크리틱은 opt-in 이라 대부분의 run 에 `config.critic` 이 없다. 그런 run 은
38
+ `runsWithCritic` 에 세지 않는다 — "쓰지 않았다" 와 "썼는데 0건" 은 정책 판단이
39
+ 갈리는 서로 다른 사실이다. 읽기 전용이므로 깨진 state 파일은 건너뛴다.
40
+ """
41
+ totals = {"runsWithCritic": 0} | {name: 0 for name in _CRITIC_COUNTERS}
42
+ for state_path in sorted(task_root.glob("runs/*/state/convergence-*.json")):
43
+ try:
44
+ payload = json.loads(state_path.read_text(encoding="utf-8"))
45
+ except (OSError, json.JSONDecodeError):
46
+ continue
47
+ critic = (payload.get("config") or {}).get("critic")
48
+ if not isinstance(critic, dict):
49
+ continue
50
+ totals["runsWithCritic"] += 1
51
+ for name in _CRITIC_COUNTERS:
52
+ value = critic.get(name)
53
+ if isinstance(value, int) and not isinstance(value, bool):
54
+ totals[name] += value
55
+ return totals
56
+
57
+
26
58
  def _collect_task(entry: dict, project_root: Path) -> dict:
27
59
  """list_project_tasks entry → roll-up 한 줄. catalog 상태 필드 + time/error 측정.
28
60
 
@@ -33,7 +65,13 @@ def _collect_task(entry: dict, project_root: Path) -> dict:
33
65
  runs, _ = load_runs(task_root)
34
66
  time_agg = aggregate_time(runs, project_root)
35
67
  records, _ = parse_records(glob_error_logs(task_root))
68
+ critic = _collect_critic(task_root)
36
69
  return {
70
+ "criticRuns": critic["runsWithCritic"],
71
+ "criticGapsProposed": critic["gapsProposed"],
72
+ "criticGapsMerged": critic["gapsMerged"],
73
+ "criticGapsRejected": critic["gapsRejected"],
74
+ "criticGapsUnverified": critic["gapsUnverified"],
37
75
  "taskKey": entry.get("taskKey", ""),
38
76
  "taskGroup": entry.get("taskGroup", ""),
39
77
  "taskId": entry.get("taskId", ""),
@@ -62,10 +100,21 @@ def _tally(tasks: list[dict], key: str) -> dict:
62
100
  return dict(sorted(counts.items()))
63
101
 
64
102
 
103
+ def _critic_totals(tasks: list[dict]) -> dict:
104
+ return {
105
+ "runsWithCritic": sum(t["criticRuns"] for t in tasks),
106
+ "gapsProposed": sum(t["criticGapsProposed"] for t in tasks),
107
+ "gapsMerged": sum(t["criticGapsMerged"] for t in tasks),
108
+ "gapsRejected": sum(t["criticGapsRejected"] for t in tasks),
109
+ "gapsUnverified": sum(t["criticGapsUnverified"] for t in tasks),
110
+ }
111
+
112
+
65
113
  def build_rollup(project_root: Path, task_group: str | None) -> dict:
66
114
  entries = list_project_tasks(project_root, task_group=task_group or None)
67
115
  tasks = [_collect_task(e, project_root) for e in entries]
68
116
  totals = {
117
+ "critic": _critic_totals(tasks),
69
118
  "runs": sum(t["runCount"] for t in tasks),
70
119
  "cpuSumMs": sum(t["cpuSumMs"] for t in tasks),
71
120
  "wallClockMs": sum(t["wallClockMs"] for t in tasks),
@@ -29,6 +29,17 @@ _CONTRACT_RE = re.compile(r"^contract:\s*(?P<rule>.+?)\s*$")
29
29
  _BRIEF_HEADING_RE = re.compile(r"^(#{1,6}\s+.+?)\s*$")
30
30
  _FENCE_RE = re.compile(r"^\s*(`{3,}|~{3,})")
31
31
 
32
+ _END_STATE_ID_RE = re.compile(r"^(?:EB|PB|EO)-\d{3}$")
33
+ _END_STATE_HEADINGS = frozenset(
34
+ {"Expected Behavior", "Preserved Behavior", "Expected Outcome"}
35
+ )
36
+ _END_STATE_BULLET_RE = re.compile(r"^-\s+(?P<id>(?:EB|PB|EO)-\d{3})\b")
37
+ # Only `## ` closes a section, mirroring validate-brief.py's section_body
38
+ # lookahead. `_BRIEF_HEADING_RE` above matches `#` through `######` and is for
39
+ # heading citations, which are checked against a different reader.
40
+ _SECTION_HEADING_RE = re.compile(r"^##\s+(?P<heading>.+?)\s*$")
41
+ _HTML_COMMENT_RE = re.compile(r"<!--.*?-->", re.DOTALL)
42
+
32
43
 
33
44
  def normalize_heading(text: str) -> str:
34
45
  """Heading text stripped of its `#` markers and surrounding whitespace.
@@ -37,6 +48,11 @@ def normalize_heading(text: str) -> str:
37
48
  Criteria` and `brief:## Acceptance Criteria` both name the same heading.
38
49
  The comparison stays exact after this — a heading the brief lacks must
39
50
  still fail.
51
+
52
+ Heading citations are the legacy form, reachable only for briefs authored
53
+ before the end-state sections existed (`brief_end_state_ids` returns an
54
+ empty set for those). A brief that pins ids takes `brief:EB-001` instead,
55
+ and `brief_citation_problem` rejects a heading there.
40
56
  """
41
57
  return " ".join(text.lstrip("#").split())
42
58
 
@@ -73,12 +89,62 @@ def brief_headings(brief_path: Path) -> set[str]:
73
89
  return headings
74
90
 
75
91
 
92
+ def brief_end_state_ids(brief_path: Path) -> set[str]:
93
+ """End-state ids the brief declares, across all three sections.
94
+
95
+ The reading rules mirror validators/validate-brief.py exactly, because that
96
+ file checks the very same lines for their format and the two readers must
97
+ not disagree about what the brief declared. Concretely: only `## ` closes a
98
+ section (its `section_body`), fenced regions are NOT skipped (its
99
+ `meaningful_bullets` knows nothing about fences), and HTML comments are
100
+ removed (it strips them file-wide before any check).
101
+
102
+ This is the one function here that does not follow brief_headings' fence
103
+ discipline, and the asymmetry is deliberate. A heading citation and an
104
+ end-state item are validated by different code; each reader has to match its
105
+ own checker. Diverging the other way — skipping a fenced item the checker
106
+ accepted — is the worse failure: the brief passes, the id is absent from the
107
+ declared set, and no phase is ever asked to account for it.
108
+
109
+ An empty set means the brief predates the end-state sections, which is what
110
+ the downstream conditional gate keys on: a legacy brief keeps the legacy
111
+ path instead of being wedged by a contract it was never written against.
112
+ """
113
+ try:
114
+ text = _HTML_COMMENT_RE.sub("", Path(brief_path).read_text(encoding="utf-8"))
115
+ except OSError:
116
+ return set()
117
+
118
+ ids: set[str] = set()
119
+ section = ""
120
+ for line in text.splitlines():
121
+ if m := _SECTION_HEADING_RE.match(line):
122
+ heading = normalize_heading(m.group("heading"))
123
+ section = heading if heading in _END_STATE_HEADINGS else ""
124
+ continue
125
+ if not section:
126
+ continue
127
+ if m := _END_STATE_BULLET_RE.match(line.strip()):
128
+ ids.add(m.group("id"))
129
+ return ids
130
+
131
+
76
132
  @dataclass(frozen=True)
77
133
  class SourceRef:
78
134
  kind: Literal["brief", "derived", "contract", "invalid"]
79
135
  value: str = ""
80
136
  reason: str = ""
81
137
 
138
+ @property
139
+ def is_end_state_id(self) -> bool:
140
+ """True when a `brief:` citation names an end-state id rather than a heading.
141
+
142
+ Heading citations survive only for briefs authored before the end-state
143
+ sections existed; the id form is what makes a citation checkable against
144
+ a specific reporter line instead of a heading every brief carries.
145
+ """
146
+ return self.kind == "brief" and bool(_END_STATE_ID_RE.match(self.value))
147
+
82
148
 
83
149
  def parse_source(raw: str) -> SourceRef:
84
150
  text = (raw or "").strip()
@@ -97,6 +163,36 @@ def parse_source(raw: str) -> SourceRef:
97
163
  return SourceRef("invalid")
98
164
 
99
165
 
166
+ def brief_citation_problem(
167
+ ref: SourceRef, headings: set[str], end_state: set[str]
168
+ ) -> str | None:
169
+ """Why a `brief:` citation is inadmissible, or None when it is fine.
170
+
171
+ Shared by validators/validate-run.py (requirementCoverage) and
172
+ validators/validate_fanout.py (fan-out packets): both decide admissibility
173
+ the same way and differ only in how they word the failure, so the decision
174
+ lives here and the wording stays with the caller.
175
+
176
+ A brief that pins ids refuses heading citations outright: every brief
177
+ carries the same generic headings, so a heading cannot say WHICH reporter
178
+ line the item came from — that is the loophole the id form closes.
179
+ """
180
+ if ref.kind != "brief":
181
+ return None
182
+ if end_state:
183
+ if not ref.is_end_state_id:
184
+ return (
185
+ f"cites `brief:{ref.value}`, but this brief pins end-state ids. "
186
+ "Cite the end-state id the item came from (`brief:EB-001`)"
187
+ )
188
+ if ref.value not in end_state:
189
+ return f"cites `brief:{ref.value}` which the brief does not declare"
190
+ return None
191
+ if headings and normalize_heading(ref.value) not in headings:
192
+ return f"cites brief heading `{ref.value}` which does not exist in the brief"
193
+ return None
194
+
195
+
100
196
  def resolve_chain(rows: dict[str, SourceRef]) -> dict[str, str]:
101
197
  """Walk each row's derivation chain to its terminator.
102
198
 
@@ -123,7 +219,8 @@ def resolve_chain(rows: dict[str, SourceRef]) -> dict[str, str]:
123
219
  continue
124
220
  result[rid] = (
125
221
  f"unrecognized source on `{cursor}` — must be one of "
126
- "`brief:<heading>`, `derived:R-NNN <reason>`, `contract:<rule>`"
222
+ "`brief:EB-001` (or the legacy `brief:<heading>`), "
223
+ "`derived:R-NNN — <reason>`, `contract:<rule>`"
127
224
  )
128
225
  break
129
226
  return result
@@ -0,0 +1,47 @@
1
+ """Read the Stage Map stage numbers a prose cell cites.
2
+
3
+ Two readers depend on this grammar and must not drift apart: the coverage
4
+ validator proving every stage traces back to a requirement, and the
5
+ incremental-scope back-trace resolving which stages an answered clarification
6
+ touches. A second copy would let one side accept a citation the other rejects,
7
+ which decides whether a re-run narrows or stays full.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import re
12
+
13
+ _RANGE = r"(?:[-–]|\bto\b|\bthrough\b)"
14
+ _ITEM = rf"\d+(?:\s*{_RANGE}\s*\d+)?"
15
+ # `, and` / `, &` is one separator, not a separator followed by a stray word:
16
+ # `Stages 1, 2, and 3` is the idiomatic plural and must not orphan stage 3.
17
+ _SEP = r"(?:,\s*(?:and|&)?|and|&)"
18
+ _LIST_RE = re.compile(
19
+ rf"\bstages?[ \t]*({_ITEM}(?:\s*{_SEP}\s*{_ITEM})*)",
20
+ re.IGNORECASE,
21
+ )
22
+ _ITEM_RE = re.compile(rf"(\d+)(?:\s*{_RANGE}\s*(\d+))?", re.IGNORECASE)
23
+ RANGE_MAX_SPAN = 64
24
+
25
+
26
+ def cited_stage_numbers(text: str) -> set[int]:
27
+ """Stage numbers *text* cites, in every prose form a planner writes.
28
+
29
+ Numbers stay anchored to a word-initial `stage`/`stages` token on the same
30
+ line; harvesting bare numbers — or letting the anchor reach across a line
31
+ break, or match the tail of `Substage`/`Backstage` — would let any prose,
32
+ including a row disclaiming every stage, justify any stage.
33
+ """
34
+ cited: set[int] = set()
35
+ for span in _LIST_RE.finditer(text):
36
+ for item in _ITEM_RE.finditer(span.group(1)):
37
+ start = int(item.group(1))
38
+ cited.add(start)
39
+ if item.group(2) is None:
40
+ continue
41
+ end = int(item.group(2))
42
+ cited.add(end)
43
+ # A reversed or absurdly wide range is a typo, not a citation of
44
+ # everything between its endpoints.
45
+ if 0 <= end - start <= RANGE_MAX_SPAN:
46
+ cited.update(range(start, end))
47
+ return cited
@@ -43,14 +43,15 @@ PHASE_RULES: dict[str, dict[str, str]] = {
43
43
  " - work-category classification (bugfix / feature / refactor / ops / improvement)\n"
44
44
  " - routing decision toward the next safe lifecycle phase\n"
45
45
  " - missing-input list and clarification requests\n"
46
- " - approval / confirmation checkpoints recorded for the next phase"
46
+ " - approval / confirmation checkpoints recorded for the next phase\n"
47
+ " - one endStateCoverage row per brief end-state id (this phase authors no goal of its own)"
47
48
  ),
48
49
  },
49
50
  "improvement-discovery": {
50
51
  "allowed": (
51
52
  " - improvement candidate discovery within the lens whitelist defined in `scripts/okstra_ctl/improvement_lenses.py`\n"
52
53
  " - top-N ranking (default 8, brief `candidate-cap` overrides within 1..12)\n"
53
- " - per-candidate columns Lens / Scope / Severity / Effort / Consensus / Source workers / Recommended next-phase / Evidence (path:line)\n"
54
+ " - per-candidate columns Lens / Scope / Severity / Effort / Consensus / Source workers / Recommended next-phase / Expected behavior after / Evidence (path:line)\n"
54
55
  " - Phase 1.5 reflect-back grilling log at `runs/improvement-discovery/<seq>/state/phase-1.5-grilling.md`\n"
55
56
  " - Phase 5.5 consensus classification (full / partial / contested / worker-unique)"
56
57
  ),
@@ -60,7 +61,8 @@ PHASE_RULES: dict[str, dict[str, str]] = {
60
61
  " - symptom and trigger evidence\n"
61
62
  " - root-cause hypotheses with supporting citations\n"
62
63
  " - reproduction gaps and missing observability\n"
63
- " - validation paths to confirm or refute hypotheses"
64
+ " - validation paths to confirm or refute hypotheses\n"
65
+ " - one endStateCoverage row per brief end-state id (this phase authors no goal of its own)"
64
66
  ),
65
67
  },
66
68
  "implementation-planning": {
@@ -72,7 +74,8 @@ PHASE_RULES: dict[str, dict[str, str]] = {
72
74
  " - dependency / migration risk assessment, validation checklist (pre / mid / post with exact commands), rollback strategy with revert path and trigger signal\n"
73
75
  " - every unresolved ambiguity registered as a `Blocks=approval` row in the `## 1. Clarification Items` table (do NOT create a separate `Open Questions` block under `5.5.x` — the unified table is the single home)\n"
74
76
  " - YAML frontmatter line `approved: false` awaiting human flip to `true`\n"
75
- " - self-review confirmation (spec coverage, placeholder scan, internal consistency, ambiguity, scope)"
77
+ " - self-review confirmation (spec coverage, placeholder scan, internal consistency, ambiguity, scope)\n"
78
+ " - one endStateCoverage row per brief end-state id, each mapped to the R-NNN row that carries it"
76
79
  ),
77
80
  },
78
81
  "implementation": {
@@ -373,6 +373,12 @@
373
373
  }
374
374
  },
375
375
 
376
+ "endStateCoverage": {
377
+ "type": "array",
378
+ "items": { "$ref": "#/$defs/EndStateCoverageRow" },
379
+ "description": "One row per brief end-state id. Optional at the schema layer; validate-run.py requires it when the run's brief carries the end-state sections."
380
+ },
381
+
376
382
  "missingInformation": {
377
383
  "type": "array",
378
384
  "items": { "$ref": "#/$defs/RiskRow" }
@@ -1568,6 +1574,36 @@
1568
1574
  }
1569
1575
  },
1570
1576
 
1577
+ "EndStateCoverageRow": {
1578
+ "type": "object",
1579
+ "additionalProperties": false,
1580
+ "required": ["id", "disposition"],
1581
+ "properties": {
1582
+ "id": {
1583
+ "type": "string",
1584
+ "pattern": "^(EB|PB|EO)-\\d{3}$",
1585
+ "description": "The brief end-state id this row accounts for."
1586
+ },
1587
+ "disposition": {
1588
+ "type": "string",
1589
+ "enum": ["addressed", "deferred", "not-applicable", "blocked"]
1590
+ },
1591
+ "coveredBy": {
1592
+ "type": "string",
1593
+ "description": "Anchor in THIS phase's deliverable that accounts for the id — a routing decision, a root-cause candidate, an R-NNN row, or a C-NNN clarification."
1594
+ },
1595
+ "evidence": {
1596
+ "type": "array",
1597
+ "items": { "type": "string" },
1598
+ "description": "path:line references backing the mapping."
1599
+ },
1600
+ "rationale": {
1601
+ "type": "string",
1602
+ "description": "Required when disposition is not `addressed`: why this phase does not account for the id."
1603
+ }
1604
+ }
1605
+ },
1606
+
1571
1607
  "DesignSurfaceCoverage": {
1572
1608
  "type": "object",
1573
1609
  "required": ["kind", "triggerEvidence", "disposition"],
@@ -1860,6 +1896,18 @@
1860
1896
  "minItems": 1,
1861
1897
  "uniqueItems": true,
1862
1898
  "items": { "type": "string", "minLength": 1 }
1899
+ },
1900
+ "rewrittenPaths": {
1901
+ "type": "array",
1902
+ "description": "data.json paths this round actually rewrote for this cause group. A self-fix round is contractually a targeted correction, not a full draft regeneration; recording the reach is what makes that claim checkable later.",
1903
+ "uniqueItems": true,
1904
+ "items": { "type": "string", "minLength": 1 }
1905
+ },
1906
+ "outsideScopePaths": {
1907
+ "type": "array",
1908
+ "description": "The subset of rewrittenPaths that lies outside the sections this group's itemIds point at. Carrying a correction to its contradictions legitimately reaches further than the flagged item, so this is a measurement, not a violation.",
1909
+ "uniqueItems": true,
1910
+ "items": { "type": "string", "minLength": 1 }
1863
1911
  }
1864
1912
  }
1865
1913
  },
@@ -1923,6 +1971,16 @@
1923
1971
  "enum": ["majority-disagree", "coverage-gap", "non-result"]
1924
1972
  }
1925
1973
  },
1974
+ "participatingAnalysers": {
1975
+ "type": "object",
1976
+ "description": "How many rostered analysers actually cast a non-error vote in this round. The gate arithmetic is unchanged; this records the base it ran on, because a majority over two votes is a different claim than a majority over three.",
1977
+ "properties": {
1978
+ "rostered": { "type": "integer", "minimum": 0 },
1979
+ "voting": { "type": "integer", "minimum": 0 }
1980
+ },
1981
+ "required": ["rostered", "voting"],
1982
+ "additionalProperties": false
1983
+ },
1926
1984
  "selfFixRoundsApplied": { "type": "integer", "minimum": 0 },
1927
1985
  "selfFixGroups": {
1928
1986
  "type": "array",
@@ -1934,7 +1992,8 @@
1934
1992
  "not-attempted",
1935
1993
  "all-resolved",
1936
1994
  "no-progress",
1937
- "max-rounds-reached"
1995
+ "max-rounds-reached",
1996
+ "cause-group-recurrence"
1938
1997
  ]
1939
1998
  },
1940
1999
  "planItems": {