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.
- package/bin/okstra +24 -7
- package/docs/project-structure-overview.md +0 -1
- package/docs/superpowers/plans/2026-05-25-okstra-project-root-rename.md +0 -1
- package/docs/superpowers/plans/2026-06-07-stage-conformance-qa-phase2.md +275 -0
- package/docs/superpowers/plans/2026-06-07-stage-conformance-qa-phase3.md +282 -0
- package/docs/superpowers/plans/2026-06-07-stage-conformance-qa-phase4a.md +147 -0
- package/docs/superpowers/plans/2026-06-07-stage-conformance-qa-phase4b.md +262 -0
- package/docs/superpowers/plans/2026-06-07-stage-conformance-qa-phase4c.md +184 -0
- package/docs/superpowers/plans/2026-06-07-stage-conformance-qa-phase4d.md +88 -0
- package/docs/superpowers/plans/2026-06-07-stage-conformance-qa-phase4e.md +250 -0
- package/docs/superpowers/plans/2026-06-07-stage-conformance-qa.md +409 -0
- package/docs/superpowers/specs/2026-06-07-stage-conformance-qa-design.md +169 -0
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/agents/workers/report-writer-worker.md +1 -1
- package/runtime/bin/lib/okstra/cli.sh +5 -1
- package/runtime/bin/lib/okstra/usage.sh +5 -0
- package/runtime/bin/okstra-inject-report-index.py +66 -0
- package/runtime/bin/okstra.sh +1 -0
- package/runtime/prompts/profiles/_implementation-verifier.md +23 -2
- package/runtime/prompts/profiles/final-verification.md +1 -0
- package/runtime/prompts/profiles/implementation-planning.md +4 -0
- package/runtime/prompts/profiles/improvement-discovery.md +1 -0
- package/runtime/python/okstra_ctl/clarification_items.py +10 -1
- package/runtime/python/okstra_ctl/conformance.py +270 -0
- package/runtime/python/okstra_ctl/paths.py +2 -0
- package/runtime/python/okstra_ctl/render_final_report.py +221 -2
- package/runtime/python/okstra_ctl/report_views.py +23 -4
- package/runtime/python/okstra_ctl/run.py +29 -0
- package/runtime/skills/okstra-run/SKILL.md +12 -0
- package/runtime/skills/okstra-setup/SKILL.md +35 -0
- package/runtime/templates/reports/i18n/en.json +6 -0
- package/runtime/templates/reports/i18n/ko.json +6 -0
- package/runtime/validators/lib/fixtures.sh +9 -0
- package/runtime/validators/validate-implementation-plan-stages.py +28 -3
- package/runtime/validators/validate-run.py +136 -1
- package/runtime/validators/validate_improvement_report.py +5 -1
- package/src/okstra-dirs.mjs +1 -1
- 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,
|
|
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
|
-
|
|
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
|
-
|
|
173
|
-
|
|
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"
|
|
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
|
|
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–
|
|
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..
|
|
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–
|
|
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
|