okstra 0.30.3 → 0.32.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 (69) hide show
  1. package/docs/kr/architecture.md +2 -2
  2. package/docs/kr/cli.md +2 -2
  3. package/package.json +1 -1
  4. package/runtime/BUILD.json +2 -2
  5. package/runtime/agents/SKILL.md +7 -5
  6. package/runtime/agents/workers/claude-worker.md +1 -1
  7. package/runtime/agents/workers/codex-worker.md +23 -6
  8. package/runtime/agents/workers/gemini-worker.md +23 -6
  9. package/runtime/agents/workers/report-writer-worker.md +45 -66
  10. package/runtime/bin/okstra-codex-exec.sh +31 -0
  11. package/runtime/bin/okstra-gemini-exec.sh +26 -0
  12. package/runtime/bin/okstra-render-final-report.py +101 -0
  13. package/runtime/bin/okstra-render-report-views.py +17 -10
  14. package/runtime/bin/okstra-token-usage.py +3 -1
  15. package/runtime/python/lib/okstra/globals.sh +1 -1
  16. package/runtime/python/lib/okstra/usage.sh +2 -2
  17. package/runtime/python/okstra_ctl/final_report_schema.py +253 -0
  18. package/runtime/python/okstra_ctl/models.py +2 -0
  19. package/runtime/python/okstra_ctl/render_final_report.py +201 -0
  20. package/runtime/python/okstra_ctl/report_views.py +276 -297
  21. package/runtime/python/okstra_ctl/run.py +1 -1
  22. package/runtime/python/okstra_ctl/wizard.py +53 -14
  23. package/runtime/python/okstra_ctl/workers.py +45 -11
  24. package/runtime/python/okstra_token_usage/__init__.py +5 -1
  25. package/runtime/python/okstra_token_usage/cli.py +66 -36
  26. package/runtime/python/okstra_token_usage/pricing.py +1 -0
  27. package/runtime/python/okstra_token_usage/report.py +148 -65
  28. package/runtime/python/okstra_vendor/__init__.py +37 -0
  29. package/runtime/python/okstra_vendor/jinja2/__init__.py +38 -0
  30. package/runtime/python/okstra_vendor/jinja2/_identifier.py +6 -0
  31. package/runtime/python/okstra_vendor/jinja2/async_utils.py +99 -0
  32. package/runtime/python/okstra_vendor/jinja2/bccache.py +408 -0
  33. package/runtime/python/okstra_vendor/jinja2/compiler.py +1998 -0
  34. package/runtime/python/okstra_vendor/jinja2/constants.py +20 -0
  35. package/runtime/python/okstra_vendor/jinja2/debug.py +191 -0
  36. package/runtime/python/okstra_vendor/jinja2/defaults.py +48 -0
  37. package/runtime/python/okstra_vendor/jinja2/environment.py +1672 -0
  38. package/runtime/python/okstra_vendor/jinja2/exceptions.py +166 -0
  39. package/runtime/python/okstra_vendor/jinja2/ext.py +870 -0
  40. package/runtime/python/okstra_vendor/jinja2/filters.py +1873 -0
  41. package/runtime/python/okstra_vendor/jinja2/idtracking.py +318 -0
  42. package/runtime/python/okstra_vendor/jinja2/lexer.py +868 -0
  43. package/runtime/python/okstra_vendor/jinja2/loaders.py +693 -0
  44. package/runtime/python/okstra_vendor/jinja2/meta.py +112 -0
  45. package/runtime/python/okstra_vendor/jinja2/nativetypes.py +130 -0
  46. package/runtime/python/okstra_vendor/jinja2/nodes.py +1206 -0
  47. package/runtime/python/okstra_vendor/jinja2/optimizer.py +48 -0
  48. package/runtime/python/okstra_vendor/jinja2/parser.py +1049 -0
  49. package/runtime/python/okstra_vendor/jinja2/py.typed +0 -0
  50. package/runtime/python/okstra_vendor/jinja2/runtime.py +1062 -0
  51. package/runtime/python/okstra_vendor/jinja2/sandbox.py +436 -0
  52. package/runtime/python/okstra_vendor/jinja2/tests.py +256 -0
  53. package/runtime/python/okstra_vendor/jinja2/utils.py +766 -0
  54. package/runtime/python/okstra_vendor/jinja2/visitor.py +92 -0
  55. package/runtime/python/okstra_vendor/markupsafe/__init__.py +396 -0
  56. package/runtime/python/okstra_vendor/markupsafe/_native.py +8 -0
  57. package/runtime/python/okstra_vendor/markupsafe/py.typed +0 -0
  58. package/runtime/schemas/final-report-v1.0.schema.json +1391 -0
  59. package/runtime/skills/okstra-report-writer/SKILL.md +31 -30
  60. package/runtime/skills/okstra-run/SKILL.md +6 -4
  61. package/runtime/skills/okstra-team-contract/SKILL.md +27 -3
  62. package/runtime/templates/reports/final-report.template.md +370 -405
  63. package/runtime/templates/reports/report.css +57 -4
  64. package/runtime/templates/reports/report.js +63 -7
  65. package/runtime/templates/reports/settings.template.json +1 -0
  66. package/runtime/validators/lib/fixtures.sh +7 -7
  67. package/runtime/validators/validate-report-views.py +24 -153
  68. package/runtime/validators/validate-run.py +102 -19
  69. package/src/install.mjs +21 -1
@@ -41,11 +41,38 @@ body {
41
41
  .report-header > div { flex: 1; font-weight: 600; }
42
42
 
43
43
  main {
44
- max-width: 80ch;
44
+ max-width: 120ch;
45
45
  margin: 1.5rem auto;
46
46
  padding: 0 1rem 4rem;
47
47
  }
48
48
 
49
+ nav.toc {
50
+ margin: 1rem 0 2rem;
51
+ padding: 0.6em 1em;
52
+ border: 1px solid color-mix(in srgb, GrayText 40%, transparent);
53
+ border-radius: 4px;
54
+ background: color-mix(in srgb, CanvasText 4%, transparent);
55
+ }
56
+ nav.toc .toc-title {
57
+ font-weight: 600;
58
+ margin-bottom: 0.4em;
59
+ font-size: 0.95rem;
60
+ }
61
+ nav.toc ul {
62
+ list-style: none;
63
+ margin: 0;
64
+ padding: 0;
65
+ columns: 2;
66
+ column-gap: 1.5em;
67
+ }
68
+ @media (max-width: 60ch) {
69
+ nav.toc ul { columns: 1; }
70
+ }
71
+ nav.toc li { margin: 0.15em 0; break-inside: avoid; }
72
+ nav.toc li.toc-h3 { padding-left: 1.2em; font-size: 0.92em; }
73
+ nav.toc a { color: inherit; text-decoration: none; }
74
+ nav.toc a:hover { text-decoration: underline; }
75
+
49
76
  h1, h2, h3, h4, h5, h6 { line-height: 1.25; margin-top: 1.6em; margin-bottom: 0.4em; }
50
77
  h1 { font-size: 1.7rem; }
51
78
  h2 { font-size: 1.35rem; border-bottom: 1px solid GrayText; padding-bottom: 0.2em; }
@@ -88,6 +115,22 @@ th, td {
88
115
  padding: 0.45em 0.6em;
89
116
  vertical-align: top;
90
117
  text-align: left;
118
+ word-break: keep-all;
119
+ overflow-wrap: anywhere;
120
+ min-width: 5ch;
121
+ /* No max-width: cells that carry prose (statement / source / scope)
122
+ * absorb the remaining horizontal space within `main`'s 120ch. */
123
+ }
124
+ /* Narrow columns — header AND every body cell of this column fit
125
+ * within 30 plain chars (per `_narrow_columns()` in report_views.py).
126
+ * Examples: ID, Ticket ID, Kind, Status, Priority, Auto-spawn?,
127
+ * 에이전트, 역할, 모델, 상태. Pinning `width: 5%` makes these
128
+ * columns shrink toward the suggestion while wide neighbours absorb
129
+ * the remainder, so a long-prose row never squashes ID-style stacks
130
+ * into one-character ladders. */
131
+ td.td-narrow, th.td-narrow {
132
+ width: 5%;
133
+ white-space: nowrap;
91
134
  }
92
135
  thead th {
93
136
  position: sticky;
@@ -105,18 +148,28 @@ tr[data-response-id][data-status="obsolete"] {
105
148
  opacity: 0.65;
106
149
  }
107
150
 
108
- textarea {
151
+ textarea,
152
+ select[data-response-id],
153
+ input[data-response-id],
154
+ input[data-other-for] {
109
155
  width: 100%;
110
- min-height: 2.2em;
111
156
  font: inherit;
112
157
  padding: 0.3em 0.4em;
113
158
  border: 1px solid GrayText;
114
159
  border-radius: 3px;
115
160
  background: Canvas;
116
161
  color: CanvasText;
162
+ }
163
+ textarea {
164
+ min-height: 2.2em;
117
165
  resize: vertical;
118
166
  }
119
- textarea[disabled] { opacity: 0.55; }
167
+ input[data-other-for] {
168
+ margin-top: 0.35em;
169
+ }
170
+ textarea[disabled],
171
+ select[disabled],
172
+ input[disabled] { opacity: 0.55; }
120
173
 
121
174
  button[data-action] {
122
175
  font: inherit;
@@ -1,10 +1,14 @@
1
1
  /* Client-side glue for the okstra final-report HTML view.
2
2
  *
3
3
  * Responsibilities:
4
- * 1. Collect <textarea> values for every <tr data-response-id> whose
5
- * Status is open/answered (disabled rows are skipped automatically).
6
- * 2. Serialise them into markdown whose bytes are IDENTICAL to
7
- * scripts/okstra_ctl/report_views.py serialize_user_response.
4
+ * 1. Collect entry values for every <tr data-response-id> whose
5
+ * Status is open/answered (disabled rows skipped automatically).
6
+ * Widgets: <select> (enum decision), <input data-other-for> (기타
7
+ * input revealed when select == "__other__"), <input
8
+ * data-response-id> (material/data-point single-line), <textarea>
9
+ * (everything else / fallback).
10
+ * 2. Serialise the entries into markdown whose bytes are IDENTICAL
11
+ * to scripts/okstra_ctl/report_views.py serialize_user_response.
8
12
  * 3. Write the result to <pre id="user-response-output"> and offer a
9
13
  * [Copy] button.
10
14
  *
@@ -36,14 +40,46 @@
36
40
  return String(s == null ? "" : s).replace(/^\s+|\s+$/g, "");
37
41
  }
38
42
 
43
+ // Read the user-supplied value out of one clarification row. Returns
44
+ // the empty string when the row has no usable widget, the widget is
45
+ // disabled, the select sits on its blank "(선택)" placeholder, or the
46
+ // "기타" branch has an empty input. Caller decides whether to emit
47
+ // an entry.
48
+ function readRowValue(row) {
49
+ var sel = row.querySelector("select[data-response-id]");
50
+ if (sel) {
51
+ if (sel.disabled) return "";
52
+ var picked = sel.value;
53
+ if (picked === "") return "";
54
+ if (picked === "__other__") {
55
+ var rid = sel.getAttribute("data-response-id") || "";
56
+ var other = row.querySelector('input[data-other-for="' + rid + '"]');
57
+ return other ? trimMultiline(other.value) : "";
58
+ }
59
+ var opt = sel.options[sel.selectedIndex];
60
+ // Use the visible option text ("(a) one-time backfill") so the
61
+ // user-response sidecar stays human-readable, not just "a".
62
+ return opt ? trimMultiline(opt.textContent) : picked;
63
+ }
64
+ var ta = row.querySelector("textarea[data-response-id]");
65
+ if (ta) {
66
+ if (ta.disabled) return "";
67
+ return trimMultiline(ta.value);
68
+ }
69
+ var inp = row.querySelector("input[data-response-id]");
70
+ if (inp) {
71
+ if (inp.disabled) return "";
72
+ return trimMultiline(inp.value);
73
+ }
74
+ return "";
75
+ }
76
+
39
77
  function collectEntries() {
40
78
  var entries = [];
41
79
  var rows = document.querySelectorAll("tr[data-response-id]");
42
80
  for (var i = 0; i < rows.length; i++) {
43
81
  var row = rows[i];
44
- var ta = row.querySelector("textarea[data-response-id]");
45
- if (!ta || ta.disabled) continue;
46
- var value = trimMultiline(ta.value);
82
+ var value = readRowValue(row);
47
83
  if (!value) continue;
48
84
  entries.push({
49
85
  responseId: row.getAttribute("data-response-id") || "",
@@ -55,6 +91,25 @@
55
91
  return entries;
56
92
  }
57
93
 
94
+ // Toggle the visibility of the "기타" companion input next to each
95
+ // select whose current value is "__other__". Wired at bind() time and
96
+ // also called once for the initial state.
97
+ function bindOtherInputToggle() {
98
+ var selects = document.querySelectorAll("select[data-response-id]");
99
+ for (var i = 0; i < selects.length; i++) {
100
+ (function (sel) {
101
+ var rid = sel.getAttribute("data-response-id") || "";
102
+ var other = document.querySelector('input[data-other-for="' + rid + '"]');
103
+ if (!other) return;
104
+ var update = function () {
105
+ other.hidden = sel.value !== "__other__";
106
+ };
107
+ sel.addEventListener("change", update);
108
+ update();
109
+ })(selects[i]);
110
+ }
111
+ }
112
+
58
113
  function buildUserResponseMarkdown(runMeta, entries, createdAt) {
59
114
  var head =
60
115
  "---\n" +
@@ -132,6 +187,7 @@
132
187
  btn.addEventListener("click", copyUserResponse);
133
188
  }
134
189
  }
190
+ bindOtherInputToggle();
135
191
  }
136
192
 
137
193
  if (typeof window !== "undefined") {
@@ -135,6 +135,7 @@
135
135
  "Bash(npx -y okstra@latest:*)",
136
136
  "Bash($HOME/.okstra/bin/:*)",
137
137
  "Bash(STATE_FILE=:*)",
138
+ "Bash(ROOT=:*)",
138
139
 
139
140
  "Bash(gemini)",
140
141
  "Bash(gemini:*)",
@@ -366,21 +366,21 @@ report_lines.extend(
366
366
  report_path.parent.mkdir(parents=True, exist_ok=True)
367
367
  report_path.write_text("\n".join(report_lines) + "\n")
368
368
 
369
- # Phase 7 step 1.5 (BLOCKING) — render the slim/html sibling artifacts
370
- # next to the final-report so validate-run.py's new report-views hook
371
- # passes. The workflow validator's fixture predates that step; we
372
- # materialise both files in-place using the same single-reference-point
373
- # helper the CLI uses.
369
+ # Phase 7 step 1.5 (BLOCKING) — render the html sibling artifact next
370
+ # to the final-report so validate-run.py's report-views hook passes.
371
+ # The workflow validator's fixture predates that step; we materialise
372
+ # the html file in-place using the same single-reference-point helper
373
+ # the CLI uses.
374
374
  import os
375
375
  WORKSPACE_ROOT = os.environ.get("OKSTRA_WORKSPACE_ROOT_FOR_FIXTURE", "")
376
376
  if WORKSPACE_ROOT:
377
377
  import sys as _sys
378
378
  _sys.path.insert(0, str(Path(WORKSPACE_ROOT) / "scripts"))
379
379
  try:
380
- from okstra_ctl.report_views import RunMeta, render_both_views
380
+ from okstra_ctl.report_views import RunMeta, render_html_view
381
381
  css = (Path(WORKSPACE_ROOT) / "templates" / "reports" / "report.css").read_text(encoding="utf-8")
382
382
  js = (Path(WORKSPACE_ROOT) / "templates" / "reports" / "report.js").read_text(encoding="utf-8")
383
- render_both_views(
383
+ render_html_view(
384
384
  report_path,
385
385
  run_meta=RunMeta(
386
386
  task_key=str(task_manifest.get("taskKey", "validation/fixture")),
@@ -1,20 +1,17 @@
1
1
  #!/usr/bin/env python3
2
- """Validate the two derived final-report views produced by Phase 7 step
3
- 1.5 (``scripts/okstra-render-report-views.py``).
2
+ """Validate the self-contained HTML view produced by Phase 7 step 1.5
3
+ (``scripts/okstra-render-report-views.py``).
4
4
 
5
5
  Checks, for a given final-report MD path:
6
- 1. ``*.slim.md`` and ``*.html`` siblings both exist.
7
- 2. Slim preserves every phase-required substring from ``validate-run.py``
8
- byte-identically (drift fence vs the report_views module).
9
- 3. HTML body (``<main>`` slice) contains the same plain-text
10
- substrings (after html.unescape and the markdown ``#`` prefix is
11
- stripped).
12
- 4. HTML's §4.6 / §4.7 / §4.8 deliverable regions contain no
6
+ 1. ``*.html`` sibling exists.
7
+ 2. HTML's ``source-sha256`` in run-meta matches the current MD body —
8
+ stale html detection.
9
+ 3. HTML's §4.6 / §4.7 / §4.8 deliverable regions contain no
13
10
  ``<textarea>`` / ``<input>`` / ``<select>`` (form-attach is
14
11
  restricted to §5 clarification rows).
15
- 5. HTML has no external URLs in ``<script src=>`` / ``<link href=>`` /
12
+ 4. HTML has no external URLs in ``<script src=>`` / ``<link href=>`` /
16
13
  ``<img src=>`` — self-contained guarantee.
17
- 6. Every Response ID in HTML matches the §5 Clarification Items table
14
+ 5. Every Response ID in HTML matches the §5 Clarification Items table
18
15
  of the source MD (1:1).
19
16
 
20
17
  Exit codes: 0 on success, 1 on any failure. Failures are printed one
@@ -23,12 +20,9 @@ per line to stderr.
23
20
  from __future__ import annotations
24
21
 
25
22
  import argparse
26
- import html as html_lib
27
- import importlib.util
28
23
  import re
29
24
  import sys
30
25
  from pathlib import Path
31
- from typing import Iterable
32
26
 
33
27
  REPO_ROOT = Path(__file__).resolve().parents[1]
34
28
  SCRIPTS_DIR = REPO_ROOT / "scripts"
@@ -37,31 +31,17 @@ if str(SCRIPTS_DIR) not in sys.path:
37
31
 
38
32
  from okstra_ctl.clarification_items import parse_clarification_items # noqa: E402
39
33
  from okstra_ctl.report_views import ( # noqa: E402
40
- _strip_leading_digest_comment,
41
34
  extract_html_digest,
42
- extract_slim_digest,
43
- preserved_substrings,
44
- slim_markdown,
45
35
  source_digest,
46
36
  )
47
37
 
48
38
 
49
- def _load_validate_run() -> object:
50
- """Dynamically load validate-run.py — it's a stand-alone script
51
- with a hyphenated name, not an importable module. We snapshot the
52
- substring lists from it so drift between the two files surfaces
53
- here rather than silently in production."""
54
- path = REPO_ROOT / "validators" / "validate-run.py"
55
- spec = importlib.util.spec_from_file_location("_okstra_validate_run", path)
56
- assert spec and spec.loader, f"cannot load {path}"
57
- mod = importlib.util.module_from_spec(spec)
58
- spec.loader.exec_module(mod)
59
- return mod
60
-
39
+ _EXTERNAL_URL_RE = re.compile(
40
+ r'<(?:script|link|img|iframe|source|video|audio)\s[^>]*?(?:src|href)\s*=\s*["\']https?://',
41
+ re.IGNORECASE,
42
+ )
61
43
 
62
- def _detect_task_type(md: str) -> str | None:
63
- m = re.search(r"^- Task Type:\s*(\S+)", md, re.MULTILINE)
64
- return m.group(1).strip() if m else None
44
+ _RESPONSE_ID_ATTR_RE = re.compile(r'data-response-id="(C-\d+)"')
65
45
 
66
46
 
67
47
  def _main_body(html_text: str) -> str:
@@ -72,25 +52,6 @@ def _main_body(html_text: str) -> str:
72
52
  return html_text[start + len("<main>"): end]
73
53
 
74
54
 
75
- def _strip_md_prefix(sub: str) -> str:
76
- s = sub
77
- while s.startswith("#"):
78
- s = s[1:]
79
- return s.strip()
80
-
81
-
82
- _EXTERNAL_URL_RE = re.compile(
83
- r'<(?:script|link|img|iframe|source|video|audio)\s[^>]*?(?:src|href)\s*=\s*["\']https?://',
84
- re.IGNORECASE,
85
- )
86
-
87
- _RESPONSE_ID_ATTR_RE = re.compile(r'data-response-id="(C-\d+)"')
88
-
89
- _NO_FORM_HEADING_RE = re.compile(
90
- r"<h[23][^>]*>\s*(?:4\.6|4\.7|4\.8)[\s\S]*?</h[23]>"
91
- )
92
-
93
-
94
55
  def _no_form_sections(html_body: str) -> list[str]:
95
56
  """Return a list of strings, each being the rendered chunk of a
96
57
  no-form section (4.6 / 4.7 / 4.8) up to the next h2/h3. Used to
@@ -118,50 +79,21 @@ def validate(report_path: Path) -> list[str]:
118
79
  return [f"final-report not found: {report_path}"]
119
80
 
120
81
  md = report_path.read_text(encoding="utf-8")
121
- task_type = _detect_task_type(md)
122
- slim_path = report_path.with_name(report_path.stem + ".slim.md")
123
82
  html_path = report_path.with_name(report_path.stem + ".html")
124
83
 
125
- # (1) sibling artifacts exist
126
- if not slim_path.is_file():
127
- failures.append(f"missing slim artifact: {slim_path}")
84
+ # (1) sibling artifact exists
128
85
  if not html_path.is_file():
129
- failures.append(f"missing html artifact: {html_path}")
130
- if failures:
131
- return failures
86
+ return [f"missing html artifact: {html_path}"]
132
87
 
133
- slim = slim_path.read_text(encoding="utf-8")
134
88
  html_text = html_path.read_text(encoding="utf-8")
135
89
  html_body = _main_body(html_text)
136
- html_body_text = html_lib.unescape(html_body)
137
90
 
138
- # (2a) source-digest staleness — both derived views must carry the
139
- # sha256 of the artifact derived from the *current* MD. A mismatch
140
- # means the views were rendered against an older MD body and Phase
141
- # 7 step 1.5 was not re-run.
142
- # - HTML digest is over the MD body (after stripping any stale
143
- # leading digest comment), so the expected value is the digest
144
- # of the current MD itself.
145
- # - slim digest is over the slim BODY (so any byte-difference
146
- # between expected and actual slim shows up as a digest
147
- # mismatch). We recompute the expected slim and extract its
148
- # embedded digest for comparison — equivalent to but cheaper
149
- # than diffing the full body.
150
- expected_md_digest = source_digest(_strip_leading_digest_comment(md))
151
- expected_slim_digest = extract_slim_digest(
152
- slim_markdown(md, task_type=task_type)
153
- )
154
- slim_digest = extract_slim_digest(slim)
91
+ # (2) source-digest staleness — html's run-meta source-sha256 must
92
+ # match the current MD's digest. Mismatch means the html was
93
+ # rendered against an older MD body and Phase 7 step 1.5 was not
94
+ # re-run.
95
+ expected_md_digest = source_digest(md)
155
96
  html_digest = extract_html_digest(html_text)
156
- if slim_digest is None:
157
- failures.append(
158
- "slim missing source-sha256 header — re-render with okstra render-views"
159
- )
160
- elif slim_digest != expected_slim_digest:
161
- failures.append(
162
- f"stale slim: body sha256 {slim_digest[:12]}… does not match "
163
- f"current MD's rendered slim {str(expected_slim_digest)[:12]}…"
164
- )
165
97
  if html_digest is None:
166
98
  failures.append(
167
99
  "html missing source-sha256 in run-meta — re-render with okstra render-views"
@@ -172,31 +104,7 @@ def validate(report_path: Path) -> list[str]:
172
104
  f"current MD {expected_md_digest[:12]}…"
173
105
  )
174
106
 
175
- # (2b) drift fence: report_views.preserved_substrings(task_type)
176
- # must agree with validate-run.py's tuples for the same task_type.
177
- vr = _load_validate_run()
178
- drift = _diff_substring_lists(task_type, vr)
179
- failures.extend(drift)
180
-
181
- # (3) slim preserves every required substring byte-identically
182
- for sub in preserved_substrings(task_type):
183
- if sub in md and sub not in slim:
184
- failures.append(f"slim dropped required substring: {sub!r}")
185
- # Also forward-check against validate-run.py's tuples so a
186
- # future addition there fails this validator immediately.
187
- for sub in _validate_run_substrings(task_type, vr):
188
- if sub in md and sub not in slim:
189
- failures.append(f"slim dropped validate-run substring: {sub!r}")
190
-
191
- # (4) html preserves the same set (modulo md prefix + html.escape)
192
- for sub in preserved_substrings(task_type):
193
- if sub not in md:
194
- continue
195
- needle = _strip_md_prefix(sub)
196
- if needle not in html_body_text:
197
- failures.append(f"html body dropped substring: {needle!r}")
198
-
199
- # (5) deliverable sections contain no form controls
107
+ # (3) deliverable sections contain no form controls
200
108
  for chunk in _no_form_sections(html_body):
201
109
  if "<textarea" in chunk or "<input" in chunk or "<select" in chunk:
202
110
  failures.append(
@@ -204,11 +112,11 @@ def validate(report_path: Path) -> list[str]:
204
112
  )
205
113
  break
206
114
 
207
- # (6) no external URLs in <script src> / <link href> / etc.
115
+ # (4) no external URLs in <script src> / <link href> / etc.
208
116
  if _EXTERNAL_URL_RE.search(html_text):
209
117
  failures.append("html contains external URL in script/link/img — must be self-contained")
210
118
 
211
- # (7) Response ID parity: HTML form rows ↔ §5 C-* rows in MD.
119
+ # (5) Response ID parity: HTML form rows ↔ §5 C-* rows in MD.
212
120
  # Bidirectional — catches both "MD has C-* the HTML lost" AND
213
121
  # "HTML has stale C-* that the current MD no longer declares".
214
122
  md_ids = _md_response_ids(md)
@@ -221,43 +129,6 @@ def validate(report_path: Path) -> list[str]:
221
129
  return failures
222
130
 
223
131
 
224
- def _validate_run_substrings(task_type: str | None, vr) -> Iterable[str]:
225
- if task_type == "implementation":
226
- return getattr(vr, "IMPLEMENTATION_REQUIRED_SECTIONS", ())
227
- if task_type == "final-verification":
228
- return getattr(vr, "FINAL_VERIFICATION_REQUIRED_SECTIONS", ())
229
- return ()
230
-
231
-
232
- def _diff_substring_lists(task_type: str | None, vr) -> list[str]:
233
- """Surface drift between report_views and validate-run substring
234
- tuples. The two MUST stay in lock-step."""
235
- failures: list[str] = []
236
- from okstra_ctl.report_views import (
237
- PRESERVED_SUBSTRINGS_IMPLEMENTATION,
238
- PRESERVED_SUBSTRINGS_FINAL_VERIFICATION,
239
- )
240
- impl_vr = set(getattr(vr, "IMPLEMENTATION_REQUIRED_SECTIONS", ()))
241
- impl_rv = set(PRESERVED_SUBSTRINGS_IMPLEMENTATION)
242
- if impl_vr != impl_rv:
243
- missing_in_views = impl_vr - impl_rv
244
- extra_in_views = impl_rv - impl_vr
245
- failures.append(
246
- "drift: PRESERVED_SUBSTRINGS_IMPLEMENTATION vs validate-run "
247
- f"IMPLEMENTATION_REQUIRED_SECTIONS — missing in views {missing_in_views or '∅'}, "
248
- f"extra in views {extra_in_views or '∅'}"
249
- )
250
- fv_vr = set(getattr(vr, "FINAL_VERIFICATION_REQUIRED_SECTIONS", ()))
251
- fv_rv = set(PRESERVED_SUBSTRINGS_FINAL_VERIFICATION)
252
- if fv_vr != fv_rv:
253
- failures.append(
254
- "drift: PRESERVED_SUBSTRINGS_FINAL_VERIFICATION vs validate-run "
255
- f"FINAL_VERIFICATION_REQUIRED_SECTIONS — missing in views {fv_vr - fv_rv or '∅'}, "
256
- f"extra in views {fv_rv - fv_vr or '∅'}"
257
- )
258
- return failures
259
-
260
-
261
132
  def _md_response_ids(md: str) -> list[str]:
262
133
  items = parse_clarification_items(md) or []
263
134
  return sorted({it.row_id for it in items if re.fullmatch(r"C-\d+", it.row_id)})
@@ -265,7 +136,7 @@ def _md_response_ids(md: str) -> list[str]:
265
136
 
266
137
  def main(argv: list[str] | None = None) -> int:
267
138
  parser = argparse.ArgumentParser(
268
- description="Validate slim/html derived views of an okstra final-report."
139
+ description="Validate the self-contained HTML view of an okstra final-report."
269
140
  )
270
141
  parser.add_argument(
271
142
  "report_path",