okstra 0.54.0 → 0.56.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 (39) hide show
  1. package/bin/okstra +24 -7
  2. package/docs/project-structure-overview.md +0 -1
  3. package/docs/superpowers/plans/2026-05-25-okstra-project-root-rename.md +0 -1
  4. package/docs/superpowers/plans/2026-06-07-stage-conformance-qa-phase2.md +275 -0
  5. package/docs/superpowers/plans/2026-06-07-stage-conformance-qa-phase3.md +282 -0
  6. package/docs/superpowers/plans/2026-06-07-stage-conformance-qa-phase4a.md +147 -0
  7. package/docs/superpowers/plans/2026-06-07-stage-conformance-qa-phase4b.md +262 -0
  8. package/docs/superpowers/plans/2026-06-07-stage-conformance-qa-phase4c.md +184 -0
  9. package/docs/superpowers/plans/2026-06-07-stage-conformance-qa-phase4d.md +88 -0
  10. package/docs/superpowers/plans/2026-06-07-stage-conformance-qa-phase4e.md +250 -0
  11. package/docs/superpowers/plans/2026-06-07-stage-conformance-qa.md +409 -0
  12. package/docs/superpowers/specs/2026-06-07-stage-conformance-qa-design.md +169 -0
  13. package/package.json +1 -1
  14. package/runtime/BUILD.json +2 -2
  15. package/runtime/agents/workers/report-writer-worker.md +1 -1
  16. package/runtime/bin/lib/okstra/cli.sh +5 -1
  17. package/runtime/bin/lib/okstra/usage.sh +5 -0
  18. package/runtime/bin/okstra-inject-report-index.py +66 -0
  19. package/runtime/bin/okstra.sh +1 -0
  20. package/runtime/prompts/profiles/_implementation-verifier.md +23 -2
  21. package/runtime/prompts/profiles/final-verification.md +1 -0
  22. package/runtime/prompts/profiles/implementation-planning.md +4 -0
  23. package/runtime/prompts/profiles/improvement-discovery.md +1 -0
  24. package/runtime/python/okstra_ctl/clarification_items.py +10 -1
  25. package/runtime/python/okstra_ctl/conformance.py +270 -0
  26. package/runtime/python/okstra_ctl/paths.py +2 -0
  27. package/runtime/python/okstra_ctl/render_final_report.py +221 -2
  28. package/runtime/python/okstra_ctl/report_views.py +23 -4
  29. package/runtime/python/okstra_ctl/run.py +29 -0
  30. package/runtime/skills/okstra-run/SKILL.md +12 -0
  31. package/runtime/skills/okstra-setup/SKILL.md +35 -0
  32. package/runtime/templates/reports/i18n/en.json +6 -0
  33. package/runtime/templates/reports/i18n/ko.json +6 -0
  34. package/runtime/validators/lib/fixtures.sh +9 -0
  35. package/runtime/validators/validate-implementation-plan-stages.py +28 -3
  36. package/runtime/validators/validate-run.py +136 -1
  37. package/runtime/validators/validate_improvement_report.py +5 -1
  38. package/src/okstra-dirs.mjs +1 -1
  39. package/src/migrate.mjs +0 -146
@@ -10,9 +10,17 @@ the canonical user-facing markdown.
10
10
  Why this exists: prior to v0.32, report-writer-worker wrote the markdown
11
11
  directly. Free-form authoring led to silent contract violations — missing
12
12
  columns in the Execution Status table, omitted §4 phase-continuation
13
- rows, invented ``## Index`` sections. Routing everything through one
13
+ rows, ad-hoc ``## Index`` sections. Routing everything through one
14
14
  template + schema cuts those failure modes to zero.
15
15
 
16
+ The top-of-report ``## Index`` is now a *deterministic* post-render
17
+ section: after Jinja2 renders the body, ``_inject_index_and_anchors``
18
+ appends a scroll anchor to every heading and every ID-defining table row,
19
+ links in-body ID references to their definition, and builds the index
20
+ (section list + ID index) — so every ``FU-001`` / ``E-001`` / ``S-001``
21
+ token is clickable and the reader can jump to any section. This runs on
22
+ every render (including the Phase 7 re-render) and is idempotent.
23
+
16
24
  Phase 7 mutation flow: ``okstra-token-usage.py --substitute-data`` fills
17
25
  the ``tokenUsage`` and ``executionStatus[].totalTokens`` etc. cells in
18
26
  data.json, then re-invokes this renderer so the markdown stays in sync.
@@ -27,6 +35,7 @@ from __future__ import annotations
27
35
 
28
36
  import json
29
37
  import os
38
+ import re
30
39
  import sys
31
40
  from pathlib import Path
32
41
  from typing import Any
@@ -110,6 +119,215 @@ def _yaml_inline_list(values: list[str]) -> str:
110
119
  return "[" + ", ".join(_yaml_scalar(v) for v in values) + "]"
111
120
 
112
121
 
122
+ # --- Index / scroll-anchor post-render pass -------------------------------
123
+ # After Jinja2 renders the body, every report gets a top-of-report index and
124
+ # clickable scroll anchors. An ID is *defined* when it is the leading token of
125
+ # a table row's first cell (`| **FU-001**<br>… |` or `| C-001 | …`); that row
126
+ # gets an `<a id="…">` anchor. Every other in-body mention of a *uniquely*
127
+ # defined ID becomes a `[ID](#anchor)` link. Same ID string defined in two
128
+ # sections (e.g. `C-001` in §1 Clarification AND §6.1 Consensus) gets two
129
+ # distinct anchors and its bare references are left unlinked — an ambiguous
130
+ # reference must not silently jump to the wrong row.
131
+ _HEADING_RE = re.compile(r"^(#{1,6})[ \t]+(.*?)[ \t]*$")
132
+ _FENCE_RE = re.compile(r"^[ \t]*(?:```|~~~)")
133
+ _FIRST_CELL_ID_RE = re.compile(r"^[ \t]*\*{0,2}([A-Z]{1,4}-\d{3,})\b")
134
+ # A reference token: an ID not glued to a word char / `-` on either side and
135
+ # not prefixed by `:` (which would make it a `<worker>:<item-id>` source ref).
136
+ _REF_ID_RE = re.compile(r"(?<![:\w-])[A-Z]{1,4}-\d{3,}(?![\w-])")
137
+
138
+
139
+ def _slugify(text: str) -> str:
140
+ slug = re.sub(r"[^\w\s-]", "", text.strip().lower())
141
+ slug = re.sub(r"[\s_]+", "-", slug).strip("-")
142
+ return slug or "section"
143
+
144
+
145
+ def _dedupe(base: str, used: set[str]) -> str:
146
+ candidate, suffix = base, 1
147
+ while candidate in used:
148
+ suffix += 1
149
+ candidate = f"{base}-{suffix}"
150
+ used.add(candidate)
151
+ return candidate
152
+
153
+
154
+ def _code_line_mask(lines: list[str]) -> list[bool]:
155
+ """Mark lines the anchor / reference passes must never rewrite: fenced
156
+ code blocks (git status dumps, etc.) and the leading YAML frontmatter
157
+ (a `task-id` that happens to look like an ID must not be linkified)."""
158
+ frontmatter_end = -1
159
+ if lines and lines[0].strip() == "---":
160
+ for k in range(1, len(lines)):
161
+ if lines[k].strip() == "---":
162
+ frontmatter_end = k
163
+ break
164
+ mask, in_fence = [], False
165
+ for i, line in enumerate(lines):
166
+ if i <= frontmatter_end:
167
+ mask.append(True)
168
+ elif _FENCE_RE.match(line):
169
+ mask.append(True)
170
+ in_fence = not in_fence
171
+ else:
172
+ mask.append(in_fence)
173
+ return mask
174
+
175
+
176
+ def _first_cell(line: str) -> str | None:
177
+ if not line.lstrip().startswith("|"):
178
+ return None
179
+ parts = line.split("|")
180
+ return parts[1] if len(parts) >= 3 else None
181
+
182
+
183
+ def _scan_structure(lines: list[str], mask: list[bool]) -> tuple[list, list]:
184
+ """Collect (level, text, slug, line_idx) headings and {id, section,
185
+ line} definitions in document order. H1 (the title) is skipped."""
186
+ headings: list = []
187
+ definitions: list = []
188
+ used_slugs: set[str] = set()
189
+ current_section: str | None = None
190
+ for i, line in enumerate(lines):
191
+ if mask[i]:
192
+ continue
193
+ heading = _HEADING_RE.match(line)
194
+ if heading:
195
+ if len(heading.group(1)) < 2:
196
+ continue # H1 title — not an index entry, no anchor
197
+ text = heading.group(2).strip()
198
+ slug = _dedupe(_slugify(text), used_slugs)
199
+ headings.append((len(heading.group(1)), text, slug, i))
200
+ current_section = text
201
+ continue
202
+ cell = _first_cell(line)
203
+ if cell is None:
204
+ continue
205
+ match = _FIRST_CELL_ID_RE.match(cell)
206
+ if match:
207
+ definitions.append({"id": match.group(1), "section": current_section, "line": i})
208
+ return headings, definitions
209
+
210
+
211
+ def _assign_anchors(definitions: list) -> dict[str, str]:
212
+ """Give every definition a unique anchor; return the id→anchor map for
213
+ the subset of IDs that are defined exactly once (safe to link)."""
214
+ used: set[str] = set()
215
+ counts: dict[str, int] = {}
216
+ for d in definitions:
217
+ counts[d["id"]] = counts.get(d["id"], 0) + 1
218
+ for d in definitions:
219
+ d["anchor"] = _dedupe(d["id"].lower(), used)
220
+ return {d["id"]: d["anchor"] for d in definitions if counts[d["id"]] == 1}
221
+
222
+
223
+ def _inject_anchors(lines: list[str], headings: list, definitions: list) -> None:
224
+ for _level, _text, slug, i in headings:
225
+ if "<a id=" not in lines[i]:
226
+ lines[i] = lines[i].rstrip() + f' <a id="{slug}"></a>'
227
+ for d in definitions:
228
+ i = d["line"]
229
+ pos = lines[i].index(d["id"])
230
+ lines[i] = lines[i][:pos] + f'<a id="{d["anchor"]}"></a>' + lines[i][pos:]
231
+
232
+
233
+ def _maybe_link(match: re.Match, id_to_anchor: dict[str, str]) -> str:
234
+ token = match.group(0)
235
+ anchor = id_to_anchor.get(token)
236
+ if anchor is None:
237
+ return token
238
+ # The definition site is already `<a id="…"></a>**FU-001**`; never wrap it.
239
+ if match.string[: match.start()].rstrip("*").endswith("</a>"):
240
+ return token
241
+ return f"[{token}](#{anchor})"
242
+
243
+
244
+ def _link_references(lines: list[str], mask: list[bool], id_to_anchor: dict[str, str]) -> None:
245
+ if not id_to_anchor:
246
+ return
247
+ for i, line in enumerate(lines):
248
+ if mask[i]:
249
+ continue
250
+ # Split on backticks so inline-code spans (odd segments) are skipped.
251
+ segments = line.split("`")
252
+ for j in range(0, len(segments), 2):
253
+ segments[j] = _REF_ID_RE.sub(lambda m: _maybe_link(m, id_to_anchor), segments[j])
254
+ lines[i] = "`".join(segments)
255
+
256
+
257
+ def _build_index(headings: list, definitions: list, labels: dict) -> list[str]:
258
+ # The section list / ID index use bold labels, not `###` sub-headings, so
259
+ # the HTML view's auto-TOC (report_views._build_toc) doesn't pick them up
260
+ # as navigable headings.
261
+ heading = labels.get("heading", "Index")
262
+ block = [f'## {heading} <a id="report-index"></a>', "", f'**{labels.get("sectionsLabel", "Sections")}**', ""]
263
+ for level, text, slug, _i in headings:
264
+ block.append(f'{" " * (level - 2)}- [{text}](#{slug})')
265
+ block += ["", f'**{labels.get("idIndexLabel", "ID Index")}**', ""]
266
+ if not definitions:
267
+ block += [labels.get("noIds", "- (no tracked IDs in this report.)"), ""]
268
+ return block
269
+ groups: list = []
270
+ order: dict = {}
271
+ for d in definitions:
272
+ section = d["section"] or "—"
273
+ if section not in order:
274
+ order[section] = len(groups)
275
+ groups.append((section, []))
276
+ groups[order[section]][1].append((d["id"], d["anchor"]))
277
+ for section, items in groups:
278
+ links = ", ".join(f"[{tok}](#{anchor})" for tok, anchor in items)
279
+ block.append(f"- **{section}**: {links}")
280
+ block.append("")
281
+ return block
282
+
283
+
284
+ def _inject_index_and_anchors(markdown: str, dictionary: dict | None) -> str:
285
+ """Append scroll anchors + a top-of-report index to a rendered report.
286
+ Idempotent: re-running on already-anchored markdown is a no-op for
287
+ headings (anchor already present) and re-derives the same anchors."""
288
+ # Idempotent: a markdown that already carries the index anchor has been
289
+ # processed (or hand-seeded) — re-running must not stack a second index.
290
+ if '<a id="report-index"' in markdown:
291
+ return markdown
292
+ labels = (dictionary or {}).get("index", {})
293
+ lines = markdown.split("\n")
294
+ mask = _code_line_mask(lines)
295
+ headings, definitions = _scan_structure(lines, mask)
296
+ if not headings:
297
+ return markdown
298
+ id_to_anchor = _assign_anchors(definitions)
299
+ _inject_anchors(lines, headings, definitions)
300
+ _link_references(lines, mask, id_to_anchor)
301
+ insert_at = headings[0][3]
302
+ lines = lines[:insert_at] + _build_index(headings, definitions, labels) + lines[insert_at:]
303
+ return "\n".join(lines)
304
+
305
+
306
+ def inject_index_into_file(md_path: Path, *, report_language: str = "en") -> int:
307
+ """Apply the top-of-report index + scroll anchors to an already-written
308
+ markdown report, in place. This is the seam for task-types that author
309
+ the markdown free-form (``improvement-discovery``) instead of through the
310
+ data.json renderer — every other task-type gets the same treatment inside
311
+ ``render()``. Idempotent (the index anchor guards re-runs). Returns the
312
+ number of bytes written.
313
+ """
314
+ if not md_path.is_file():
315
+ raise FinalReportRenderError(f"report markdown not found: {md_path}")
316
+ if report_language not in SUPPORTED_LANGS:
317
+ raise FinalReportRenderError(
318
+ f"report_language must be one of {SUPPORTED_LANGS}, got {report_language!r}"
319
+ )
320
+ try:
321
+ dictionary = load_dictionary(report_language)
322
+ except I18nError as exc:
323
+ raise FinalReportRenderError(str(exc)) from exc
324
+ injected = _inject_index_and_anchors(md_path.read_text(encoding="utf-8"), dictionary)
325
+ tmp = md_path.with_suffix(md_path.suffix + f".tmp.{os.getpid()}")
326
+ tmp.write_text(injected, encoding="utf-8")
327
+ tmp.replace(md_path)
328
+ return len(injected.encode("utf-8"))
329
+
330
+
113
331
  def _enforce_schema(data: dict) -> None:
114
332
  """렌더 전에 data.json 을 스키마에 대해 검증하는 seam.
115
333
 
@@ -203,7 +421,8 @@ def render(
203
421
 
204
422
  try:
205
423
  template = env.get_template(template_path.name)
206
- return template.render(**data)
424
+ rendered = template.render(**data)
425
+ return _inject_index_and_anchors(rendered, dictionary)
207
426
  except I18nError as exc:
208
427
  raise FinalReportRenderError(
209
428
  f"i18n lookup failed while rendering {template_path.name}: {exc}"
@@ -169,8 +169,12 @@ def _markdown_to_html(
169
169
  m_heading = _HEADING_PATTERN.match(line)
170
170
  if m_heading:
171
171
  level = len(m_heading.group(1))
172
- text = m_heading.group(2)
173
- slug = _slugify(text)
172
+ # The final-report renderer appends an explicit scroll anchor to
173
+ # each heading (`## Verdict Card <a id="verdict-card"></a>`). Honor
174
+ # that id as the slug (keeps markdown ↔ HTML anchors consistent and
175
+ # language-independent) and drop it from the displayed text.
176
+ explicit_id, text = _split_heading_anchor(m_heading.group(2))
177
+ slug = explicit_id or _slugify(text)
174
178
  current_section_path = _update_section_path(current_section_path, level, line)
175
179
  out.append(f'<h{level} id="{slug}">{_inline(text)}</h{level}>')
176
180
  headings.append((level, slug, text))
@@ -235,11 +239,26 @@ def _markdown_to_html(
235
239
  return "\n".join(out), headings
236
240
 
237
241
 
242
+ _HEADING_ANCHOR_RE = re.compile(r'\s*<a id="([^"]+)"></a>\s*$')
243
+
244
+ # The renderer's top-of-report Index section (`## Index`/`## 목차` carrying
245
+ # `<a id="report-index">`, followed by bold-labelled bullet lists). The whole
246
+ # section — heading through the bullet lists, up to the next h2 — is replaced
247
+ # by the auto-built nav.
238
248
  _INDEX_BLOCK_RE = re.compile(
239
- r'<h2 id="index">Index</h2>\n<ul>.*?</ul>', re.DOTALL
249
+ r'<h2 id="report-index">.*?(?=<h2|\Z)', re.DOTALL
240
250
  )
241
251
 
242
252
 
253
+ def _split_heading_anchor(text: str) -> tuple[Optional[str], str]:
254
+ """Split a trailing ``<a id="…"></a>`` off a heading's text. Returns
255
+ ``(explicit_id_or_None, display_text)``."""
256
+ match = _HEADING_ANCHOR_RE.search(text)
257
+ if match:
258
+ return match.group(1), text[: match.start()].rstrip()
259
+ return None, text
260
+
261
+
243
262
  def _build_toc(headings: list[tuple[int, str, str]]) -> str:
244
263
  """Render a ``<nav class="toc">`` block from collected h2/h3 entries.
245
264
  h1 (the report title) is omitted; h4+ are too granular for the TOC."""
@@ -247,7 +266,7 @@ def _build_toc(headings: list[tuple[int, str, str]]) -> str:
247
266
  for level, slug, text in headings:
248
267
  if level not in (2, 3):
249
268
  continue
250
- if slug == "index": # skip the source "Index" heading itself
269
+ if slug == "report-index": # skip the source Index/목차 heading itself
251
270
  continue
252
271
  cls = "toc-h2" if level == 2 else "toc-h3"
253
272
  items.append(
@@ -276,6 +276,9 @@ class PrepareInputs:
276
276
  work_category: str = ""
277
277
  base_ref: str = ""
278
278
  approved_plan_path: str = ""
279
+ # implementation 전용: `--qa-waiver "<stageKey>:<reason>"` 사용자 확인형 우회.
280
+ # prepare-time 에 task-level conformance 매니페스트 entry.waiver 를 채운다.
281
+ qa_waiver: str = ""
279
282
  stage: str = "auto"
280
283
  clarification_response_path: str = "" # absolute or empty
281
284
  # release-handoff 전용: PR 본문 템플릿 1회성 override. 빈 문자열이면
@@ -1092,6 +1095,28 @@ def _validate_prepare_inputs(project_root: Path, inp: PrepareInputs) -> list:
1092
1095
  return ctx_stage_map
1093
1096
 
1094
1097
 
1098
+ def _apply_qa_waiver_if_requested(inp: "PrepareInputs", project_root: Path) -> None:
1099
+ """`--qa-waiver` 가 있으면 task-level 매니페스트 entry 의 waiver 를 채운다."""
1100
+ if not inp.qa_waiver:
1101
+ return
1102
+ from .conformance import apply_qa_waiver, parse_qa_waiver_arg
1103
+ from .paths import task_dir
1104
+ parsed = parse_qa_waiver_arg(inp.qa_waiver)
1105
+ if parsed is None:
1106
+ raise PrepareError(
1107
+ f'--qa-waiver must be "<stageKey>:<reason>", got {inp.qa_waiver!r}'
1108
+ )
1109
+ stage_key, reason = parsed
1110
+ manifest_path = task_dir(project_root, inp.task_group, inp.task_id) / "qa" / "conformance-manifest.json"
1111
+ if not manifest_path.is_file():
1112
+ raise PrepareError(f"--qa-waiver: conformance manifest not found at {manifest_path}")
1113
+ manifest = json.loads(manifest_path.read_text())
1114
+ when = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
1115
+ if not apply_qa_waiver(manifest, stage_key, reason, at=when):
1116
+ raise PrepareError(f"--qa-waiver: stageKey {stage_key!r} not in manifest {manifest_path}")
1117
+ manifest_path.write_text(json.dumps(manifest, indent=2, ensure_ascii=False) + "\n")
1118
+
1119
+
1095
1120
  def _register_and_check_project(project_root: Path, inp: PrepareInputs) -> None:
1096
1121
  """project.json self-registration + (implementation 한정) qaCommands gate 검증."""
1097
1122
  from okstra_project import ResolverError
@@ -1120,6 +1145,7 @@ def _register_and_check_project(project_root: Path, inp: PrepareInputs) -> None:
1120
1145
  qa_errors = validate_qa_commands(project_meta.get("qaCommands"))
1121
1146
  if qa_errors:
1122
1147
  raise PrepareError(_format_qa_errors(qa_errors))
1148
+ _apply_qa_waiver_if_requested(inp, project_root)
1123
1149
 
1124
1150
 
1125
1151
  def _resolve_roster(inp: PrepareInputs, profile_file: Path) -> tuple[list[str], str]:
@@ -1860,6 +1886,8 @@ def main(argv: list[str]) -> int:
1860
1886
  p.add_argument("--critic", default="")
1861
1887
  p.add_argument("--related-tasks", default="", dest="related_tasks_raw")
1862
1888
  p.add_argument("--approved-plan", default="", dest="approved_plan_path")
1889
+ p.add_argument("--qa-waiver", default="", dest="qa_waiver",
1890
+ help='Stage conformance 우회: "<stageKey>:<reason>" (사용자 확인형, 매니페스트 entry.waiver 기록)')
1863
1891
  p.add_argument(
1864
1892
  "--stage", default="auto", dest="stage",
1865
1893
  help=(
@@ -1975,6 +2003,7 @@ def main(argv: list[str]) -> int:
1975
2003
  work_category=args.work_category,
1976
2004
  base_ref=args.base_ref,
1977
2005
  approved_plan_path=args.approved_plan_path,
2006
+ qa_waiver=args.qa_waiver,
1978
2007
  stage=args.stage,
1979
2008
  clarification_response_path=clarification_abs,
1980
2009
  pr_template_path=args.pr_template_path,
@@ -184,6 +184,18 @@ The python function underneath is mutex-protected (`~/.okstra/.locks/<task-key>.
184
184
 
185
185
  You can delete the literal state-file path after this point — its job is done. Invoke `rm` with the literal path (e.g. `rm /var/folders/.../okstra-wizard.AbCd.json`), not a shell variable.
186
186
 
187
+ ### Step 5.1 (implementation only): conformance waiver offer
188
+
189
+ `render-bundle` accepts an optional `--qa-waiver "<stageKey>:<reason>"` flag (implementation only). It records a **user-acknowledged** waiver into the task-level conformance manifest entry (`entry.waiver`), letting the run proceed when a stage's Tier 3 conformance script genuinely cannot run (e.g. the replica DB is unreachable). The waiver records the user's reason **verbatim**.
190
+
191
+ This is **never** a lead/worker self-exemption — only the user may waive. Offer it **only** when conformance BLOCKING is expected (the chosen stage declares a conformance entry whose script you cannot run in this environment). Surface it as a 3-option recommendation picker (per the run-prompt recommendation rule):
192
+
193
+ 1. (recommended) Run the conformance script — no waiver.
194
+ 2. Waive this stage — ask the user for the exact `<stageKey>` and reason, then pass `--qa-waiver "<stageKey>:<reason>"` to `render-bundle` (reason = the user's words, unedited).
195
+ 3. 직접 입력 — the user types the full `<stageKey>:<reason>` value.
196
+
197
+ When the user picks a waiver, append `--qa-waiver "<stageKey>:<reason>"` to the `render-bundle` invocation above. Omit the flag entirely otherwise (do **not** pass `--qa-waiver ""`). A malformed value or unknown `<stageKey>` aborts `render-bundle` with a `PrepareError`.
198
+
187
199
  ## Step 6: Take over as Claude lead
188
200
 
189
201
  Read `<INSTRUCTION_SET_PATH>/claude-execution-prompt.md` verbatim and enter `Claude lead` mode. The lead prompt now points to compact intake artifacts first (`active-run-context`, `analysis-profile.md`, and `analysis-packet.md`); full source files such as `analysis-material.md`, `reference-expectations.md`, and `final-report-template.md` are lazy/fallback inputs. Follow the rendered prompt order, do not preempt it.
@@ -181,6 +181,41 @@ The field is preserved across the runtime's auto-upserts of
181
181
  `updatedAt` are runtime-owned, so manual edits to `qaCommands`
182
182
  survive every subsequent `okstra setup` / `okstra run` invocation.
183
183
 
184
+ ### Step 4.6.1 (optional): `qaEnv` — Tier 3 conformance environment
185
+
186
+ `implementation` / `final-verification` verifiers run **stage
187
+ conformance scripts** (Tier 3) that may need to reach a database or an
188
+ HTTP endpoint to prove the diff satisfies upstream requirements. Declare
189
+ the environment those scripts are allowed to touch under `qaEnv`. Every
190
+ field is optional; declare only what your conformance scripts use.
191
+
192
+ ```json
193
+ "qaEnv": {
194
+ "replicaDbDsn": "<replica/test DB DSN — never shared/staging/prod>",
195
+ "appBaseUrl": "http://localhost:3000",
196
+ "envFile": ".okstra/qa.env",
197
+ "surfacePatterns": { "db": ["*.sql", "*repository*"], "http": ["*controller*"] }
198
+ }
199
+ ```
200
+
201
+ - `replicaDbDsn` — DSN the conformance script connects to. MUST be a
202
+ replica / disposable test DB, **never** a shared, staging, or
203
+ production database (conformance scripts may write).
204
+ - `appBaseUrl` — base URL for endpoint-level conformance checks
205
+ (local app only).
206
+ - `envFile` — path (under `.okstra/`) to an env file the verifier
207
+ sources before running conformance scripts.
208
+ - `surfacePatterns` — per-project **override** of the diff-surface
209
+ cross-check map (`capability → glob list`). The validator maps each
210
+ changed file to a capability surface (`db` / `http` / `io`) and fails
211
+ the run when the diff touches a surface no stage `requires`. The
212
+ built-in patterns (e.g. `*router*` for `http`, `*storage*` for `io`)
213
+ are broad and match many front-end files, so front-end-heavy repos
214
+ should override with narrower globs to avoid false BLOCKING verdicts
215
+ (Phase 4b review note). An over-broad pattern over-blocks; an
216
+ over-narrow one lets an undeclared surface through — tune to the
217
+ repo's real db/http/io file naming.
218
+
184
219
  ## Step 4.7 (automatic): project-local Claude settings symlink
185
220
 
186
221
  `okstra setup` (and `okstra run` on its first invocation per project)
@@ -137,5 +137,11 @@
137
137
  "columnRequirement": "Requirement (plan/brief citation)",
138
138
  "verificationScope": "Verification scope",
139
139
  "stageReportsLabel": "Source implementation reports (per stage)"
140
+ },
141
+ "index": {
142
+ "heading": "Index",
143
+ "sectionsLabel": "Sections",
144
+ "idIndexLabel": "ID Index",
145
+ "noIds": "- (no tracked IDs in this report.)"
140
146
  }
141
147
  }
@@ -137,5 +137,11 @@
137
137
  "columnRequirement": "Requirement (plan/brief 인용)",
138
138
  "verificationScope": "검증 범위",
139
139
  "stageReportsLabel": "stage 별 구현 리포트"
140
+ },
141
+ "index": {
142
+ "heading": "목차",
143
+ "sectionsLabel": "섹션",
144
+ "idIndexLabel": "ID 색인",
145
+ "noIds": "- (이 보고서에 추적 대상 ID 가 없습니다.)"
140
146
  }
141
147
  }
@@ -323,6 +323,15 @@ if not isinstance(required_status_entries, list):
323
323
  report_lines = [
324
324
  "# Validation Fixture Report",
325
325
  "",
326
+ # Top-of-report Index — the renderer injects this on real runs; the
327
+ # hand-crafted fixture mirrors it so validate_report's index/anchor
328
+ # contract (introduced with the clickable-ID pass) is satisfied.
329
+ '## Index <a id="report-index"></a>',
330
+ "",
331
+ "### Sections",
332
+ "",
333
+ "- [Verdict Card](#verdict-card)",
334
+ "",
326
335
  "## Verdict Card",
327
336
  "",
328
337
  "| 항목 | 값 |",
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env python3
2
- """S1–S10 checks for the Stage Map structure of an approved
2
+ """S1–S11 checks for the Stage Map structure of an approved
3
3
  implementation-planning final-report.md. Run from prepare_task_bundle
4
4
  of `implementation` task or standalone."""
5
5
 
@@ -40,7 +40,7 @@ class StageMeta:
40
40
 
41
41
  @dataclass
42
42
  class ValidationError:
43
- code: str # S1..S10
43
+ code: str # S1..S11
44
44
  stage: int # 0 = global
45
45
  message: str
46
46
 
@@ -168,6 +168,8 @@ def _check_each_stage_section(text: str, stages: List[StageMeta]) -> List[Valida
168
168
  SLICE_VALUE = re.compile(r"^\s*Slice value\s*:\s*(.+?)\s*$", re.M)
169
169
  ACCEPTANCE = re.compile(r"^\s*Acceptance\s*:\s*(.+?)\s*$", re.M)
170
170
  TDD_EXEMPTION = re.compile(r"^\s*TDD exemption\s*:\s*\S", re.M)
171
+ CONFORMANCE_TESTS = re.compile(r"^\s*Conformance tests\s*:\s*\S", re.M)
172
+ CONFORMANCE_EXEMPTION = re.compile(r"^\s*Conformance exemption\s*:\s*\S", re.M)
171
173
 
172
174
 
173
175
  def _check_slice_tdd(text: str, stages: List[StageMeta]) -> List[ValidationError]:
@@ -204,6 +206,28 @@ def _check_slice_tdd(text: str, stages: List[StageMeta]) -> List[ValidationError
204
206
  return errs
205
207
 
206
208
 
209
+ def _check_conformance_declaration(
210
+ text: str, stages: List[StageMeta]
211
+ ) -> List[ValidationError]:
212
+ """S11: 각 stage 는 conformance 검증을 선언하거나 명시적으로 면제한다.
213
+
214
+ S11 — `Conformance tests:` 라인(Tier3 검증 스크립트 선언) 또는
215
+ `Conformance exemption:` 라인(테스트 불필요 사유) 중 하나 필수.
216
+ diff 가 db/io/http surface 를 건드렸는데 아무 선언이 없는 silent-pass(DEV-9184)
217
+ 를 planning boundary 에서 차단한다.
218
+ """
219
+ errs: List[ValidationError] = []
220
+ for s in stages:
221
+ section = _slice_stage_section(text, s.stage_number)
222
+ if not (CONFORMANCE_TESTS.search(section) or CONFORMANCE_EXEMPTION.search(section)):
223
+ errs.append(ValidationError(
224
+ "S11", s.stage_number,
225
+ "S11: stage must declare 'Conformance tests:' (Tier3 검증 스크립트) "
226
+ "or 'Conformance exemption:' (사유) — stage conformance QA design §12.2",
227
+ ))
228
+ return errs
229
+
230
+
207
231
  def _check_depends_on(stages: List[StageMeta]) -> List[ValidationError]:
208
232
  errs: List[ValidationError] = []
209
233
  valid = {s.stage_number for s in stages}
@@ -274,7 +298,7 @@ def _check_parallel_safety(text: str, stages: List[StageMeta]) -> List[Validatio
274
298
 
275
299
 
276
300
  def collect_validation_errors(text: str) -> List[ValidationError]:
277
- """All S1–S10 checks against the report text; empty list means valid.
301
+ """All S1–S11 checks against the report text; empty list means valid.
278
302
 
279
303
  S1 (missing `## 5.5 Stage Map` heading) makes the rest unparseable, so it
280
304
  short-circuits. Shared by `main()` (CLI / implementation entry) and the
@@ -290,6 +314,7 @@ def collect_validation_errors(text: str) -> List[ValidationError]:
290
314
  if stages:
291
315
  errors.extend(_check_each_stage_section(text, stages))
292
316
  errors.extend(_check_slice_tdd(text, stages))
317
+ errors.extend(_check_conformance_declaration(text, stages))
293
318
  errors.extend(_check_depends_on(stages))
294
319
  errors.extend(_check_parallel_safety(text, stages))
295
320
  return errors