okstra 0.144.0 → 0.145.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/README.md +4 -1
- package/docs/architecture.md +18 -2
- package/docs/cli.md +39 -2
- package/docs/project-structure-overview.md +16 -4
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/prompts/lead/convergence.md +11 -3
- package/runtime/prompts/lead/okstra-lead-contract.md +7 -1
- package/runtime/prompts/profiles/_common-contract.md +1 -1
- package/runtime/prompts/profiles/change-impact-analysis.md +24 -0
- package/runtime/prompts/profiles/feature-analysis.md +24 -0
- package/runtime/prompts/profiles/forbidden-actions.json +18 -0
- package/runtime/prompts/profiles/project-analysis.md +24 -0
- package/runtime/prompts/wizard/prompts.ko.json +44 -1
- package/runtime/python/okstra_ctl/analysis_inputs.py +369 -0
- package/runtime/python/okstra_ctl/clarification_items.py +74 -1
- package/runtime/python/okstra_ctl/render.py +77 -4
- package/runtime/python/okstra_ctl/render_final_report.py +13 -4
- package/runtime/python/okstra_ctl/report_views.py +134 -3
- package/runtime/python/okstra_ctl/run.py +118 -0
- package/runtime/python/okstra_ctl/run_context.py +34 -2
- package/runtime/python/okstra_ctl/schema_excerpt.py +12 -4
- package/runtime/python/okstra_ctl/user_response.py +309 -3
- package/runtime/python/okstra_ctl/wizard.py +545 -32
- package/runtime/python/okstra_ctl/worker_prompt_policy.py +3 -0
- package/runtime/python/okstra_ctl/workflow.py +22 -0
- package/runtime/schemas/final-report-v1.0.schema.json +849 -3
- package/runtime/skills/okstra-run/SKILL.md +13 -1
- package/runtime/templates/reports/change-impact-analysis-input.template.md +58 -0
- package/runtime/templates/reports/feature-analysis-input.template.md +59 -0
- package/runtime/templates/reports/final-report.template.md +220 -0
- package/runtime/templates/reports/i18n/en.json +8 -0
- package/runtime/templates/reports/i18n/ko.json +8 -0
- package/runtime/templates/reports/project-analysis-input.template.md +58 -0
- package/runtime/templates/reports/report.js +84 -5
- package/runtime/templates/reports/user-response.template.md +19 -1
- package/runtime/validators/validate-report-views.py +61 -7
- package/runtime/validators/validate-run.py +42 -0
- package/runtime/validators/validate_analysis_report.py +864 -0
- package/src/commands/execute/render-bundle.mjs +3 -0
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
The sidecar format is documented in ``templates/reports/user-response.template.md``
|
|
4
4
|
and produced byte-identically by ``report_views.serialize_user_response`` (Python)
|
|
5
5
|
and ``templates/reports/report.js`` (browser). This module owns the read side of
|
|
6
|
-
the ``## APPROVAL`` block
|
|
7
|
-
|
|
6
|
+
the ``## APPROVAL`` block used by the implementation wizard and the optional
|
|
7
|
+
``## ANALYSIS REVIEW`` block used by analysis reruns.
|
|
8
8
|
"""
|
|
9
9
|
from __future__ import annotations
|
|
10
10
|
|
|
@@ -12,6 +12,7 @@ import argparse
|
|
|
12
12
|
import datetime as dt
|
|
13
13
|
import json
|
|
14
14
|
import re
|
|
15
|
+
import stat
|
|
15
16
|
import sys
|
|
16
17
|
from dataclasses import dataclass
|
|
17
18
|
from pathlib import Path
|
|
@@ -31,6 +32,19 @@ from okstra_ctl.clarification_items import (
|
|
|
31
32
|
|
|
32
33
|
_APPROVAL_HEADING_RE = re.compile(r"^## APPROVAL\s*$", re.MULTILINE)
|
|
33
34
|
_NEXT_RESPONSE_HEADING_RE = re.compile(r"^## ", re.MULTILINE)
|
|
35
|
+
_ANALYSIS_REVIEW_HEADING_RE = re.compile(r"^## ANALYSIS REVIEW\s*$", re.MULTILINE)
|
|
36
|
+
_ANALYSIS_SIDECAR_HEADING_RE = re.compile(
|
|
37
|
+
r"^## (?P<filename>user-response-[^\n]+\.md)\s*$", re.MULTILINE
|
|
38
|
+
)
|
|
39
|
+
_YAML_FRONTMATTER_RE = re.compile(
|
|
40
|
+
r"\A---[ \t]*\r?\n(?P<body>.*?)(?:\r?\n)---[ \t]*(?:\r?\n|\Z)",
|
|
41
|
+
re.DOTALL,
|
|
42
|
+
)
|
|
43
|
+
_FRONTMATTER_DELIMITER_RE = re.compile(r"^---[ \t]*$", re.MULTILINE)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class UserResponseError(ValueError):
|
|
47
|
+
"""Raised when a user-response sidecar violates its supported format."""
|
|
34
48
|
|
|
35
49
|
|
|
36
50
|
@dataclass(frozen=True)
|
|
@@ -42,6 +56,296 @@ class UserResponseApprovalRecord:
|
|
|
42
56
|
seq: str
|
|
43
57
|
|
|
44
58
|
|
|
59
|
+
@dataclass(frozen=True)
|
|
60
|
+
class AnalysisReviewRecord:
|
|
61
|
+
"""Validated user review of an analysis report."""
|
|
62
|
+
|
|
63
|
+
status: str
|
|
64
|
+
affected_ids: tuple[str, ...]
|
|
65
|
+
reason: str
|
|
66
|
+
additional_evidence: str
|
|
67
|
+
requested_scope_change: str
|
|
68
|
+
task_key: str
|
|
69
|
+
task_type: str
|
|
70
|
+
source_report: str
|
|
71
|
+
seq: str
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
_ANALYSIS_REVIEW_STATUSES = frozenset({
|
|
75
|
+
"accepted",
|
|
76
|
+
"revision-requested",
|
|
77
|
+
"rejected",
|
|
78
|
+
})
|
|
79
|
+
_ANALYSIS_REPORT_RE = re.compile(
|
|
80
|
+
r"^final-report-(?P<task_type>project-analysis|feature-analysis|"
|
|
81
|
+
r"change-impact-analysis)-(?P<seq>\d{3})\.md$"
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _quoted_review_value(block: str, key: str) -> str:
|
|
86
|
+
match = re.search(
|
|
87
|
+
rf"^- {re.escape(key)}:\s*\n((?:\s*>.*\n?)+)", block, re.MULTILINE
|
|
88
|
+
)
|
|
89
|
+
if not match:
|
|
90
|
+
return ""
|
|
91
|
+
return "\n".join(
|
|
92
|
+
re.sub(r"^\s*>\s?", "", line)
|
|
93
|
+
for line in match.group(1).splitlines()
|
|
94
|
+
).strip()
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _sidecar_metadata_value(sidecar_text: str, key: str) -> str:
|
|
98
|
+
frontmatter = _YAML_FRONTMATTER_RE.match(sidecar_text)
|
|
99
|
+
if frontmatter is None:
|
|
100
|
+
return ""
|
|
101
|
+
match = re.search(
|
|
102
|
+
rf"^{re.escape(key)}:[ \t]*(\S.*?)[ \t]*$",
|
|
103
|
+
frontmatter.group("body"),
|
|
104
|
+
re.MULTILINE,
|
|
105
|
+
)
|
|
106
|
+
return match.group(1) if match else ""
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _nearest_frontmatter_value(
|
|
110
|
+
sidecar_text: str, key: str, before_offset: int
|
|
111
|
+
) -> str:
|
|
112
|
+
delimiters = [
|
|
113
|
+
match
|
|
114
|
+
for match in _FRONTMATTER_DELIMITER_RE.finditer(sidecar_text)
|
|
115
|
+
if match.start() < before_offset
|
|
116
|
+
]
|
|
117
|
+
for opening, closing in reversed(list(zip(delimiters, delimiters[1:]))):
|
|
118
|
+
body = sidecar_text[opening.end():closing.start()]
|
|
119
|
+
match = re.search(
|
|
120
|
+
rf"^{re.escape(key)}:[ \t]*(\S.*?)[ \t]*$",
|
|
121
|
+
body,
|
|
122
|
+
re.MULTILINE,
|
|
123
|
+
)
|
|
124
|
+
if match:
|
|
125
|
+
return match.group(1)
|
|
126
|
+
return _sidecar_metadata_value(sidecar_text, key)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _parsed_created_at(value: str) -> dt.datetime | None:
|
|
130
|
+
if re.fullmatch(
|
|
131
|
+
r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z",
|
|
132
|
+
value,
|
|
133
|
+
) is None:
|
|
134
|
+
return None
|
|
135
|
+
try:
|
|
136
|
+
parsed = dt.datetime.fromisoformat(value[:-1] + "+00:00")
|
|
137
|
+
except ValueError:
|
|
138
|
+
return None
|
|
139
|
+
if parsed.tzinfo is None:
|
|
140
|
+
return None
|
|
141
|
+
return parsed.astimezone(dt.timezone.utc)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _nearest_sidecar_filename(sidecar_text: str, before_offset: int) -> str:
|
|
145
|
+
headings = [
|
|
146
|
+
match
|
|
147
|
+
for match in _ANALYSIS_SIDECAR_HEADING_RE.finditer(sidecar_text)
|
|
148
|
+
if match.start() < before_offset
|
|
149
|
+
]
|
|
150
|
+
return headings[-1].group("filename") if headings else ""
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _authoritative_analysis_review_match(
|
|
154
|
+
sidecar_text: str, matches: list[re.Match[str]]
|
|
155
|
+
) -> re.Match[str]:
|
|
156
|
+
candidates: list[tuple[dt.datetime, str, int, re.Match[str]]] = []
|
|
157
|
+
for match in matches:
|
|
158
|
+
created_at = _parsed_created_at(
|
|
159
|
+
_nearest_frontmatter_value(sidecar_text, "created-at", match.start())
|
|
160
|
+
)
|
|
161
|
+
if created_at is None:
|
|
162
|
+
continue
|
|
163
|
+
candidates.append((
|
|
164
|
+
created_at,
|
|
165
|
+
_nearest_sidecar_filename(sidecar_text, match.start()),
|
|
166
|
+
match.start(),
|
|
167
|
+
match,
|
|
168
|
+
))
|
|
169
|
+
if not candidates:
|
|
170
|
+
raise UserResponseError(
|
|
171
|
+
"ANALYSIS REVIEW requires a valid canonical created-at"
|
|
172
|
+
)
|
|
173
|
+
return max(candidates, key=lambda candidate: candidate[:3])[3]
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def parse_analysis_review(sidecar_text: str) -> AnalysisReviewRecord | None:
|
|
177
|
+
"""Parse the bounded ``## ANALYSIS REVIEW`` block from a sidecar.
|
|
178
|
+
|
|
179
|
+
A sidecar without this optional block is not a review. Once the block is
|
|
180
|
+
present, status and the fields that make a rejection actionable are
|
|
181
|
+
validated fail-closed.
|
|
182
|
+
"""
|
|
183
|
+
matches = list(_ANALYSIS_REVIEW_HEADING_RE.finditer(sidecar_text))
|
|
184
|
+
if not matches:
|
|
185
|
+
return None
|
|
186
|
+
match = _authoritative_analysis_review_match(sidecar_text, matches)
|
|
187
|
+
block = sidecar_text[match.end():]
|
|
188
|
+
next_heading = _NEXT_RESPONSE_HEADING_RE.search(block)
|
|
189
|
+
if next_heading:
|
|
190
|
+
block = block[:next_heading.start()]
|
|
191
|
+
status = _field(block, "Status")
|
|
192
|
+
if status not in _ANALYSIS_REVIEW_STATUSES:
|
|
193
|
+
raise UserResponseError("ANALYSIS REVIEW Status is invalid")
|
|
194
|
+
affected = _field(block, "Affected-IDs") or ""
|
|
195
|
+
affected_ids = tuple(item.strip() for item in affected.split(",") if item.strip())
|
|
196
|
+
reason = _quoted_review_value(block, "Reason")
|
|
197
|
+
if status in {"revision-requested", "rejected"} and (not affected_ids or not reason):
|
|
198
|
+
raise UserResponseError(
|
|
199
|
+
f"ANALYSIS REVIEW {status} requires Affected-IDs and Reason"
|
|
200
|
+
)
|
|
201
|
+
return AnalysisReviewRecord(
|
|
202
|
+
status=status,
|
|
203
|
+
affected_ids=affected_ids,
|
|
204
|
+
reason=reason,
|
|
205
|
+
additional_evidence=_quoted_review_value(block, "Additional-Evidence"),
|
|
206
|
+
requested_scope_change=_quoted_review_value(block, "Requested-Scope-Change"),
|
|
207
|
+
task_key=_nearest_frontmatter_value(
|
|
208
|
+
sidecar_text, "task-key", match.start()
|
|
209
|
+
),
|
|
210
|
+
task_type=_nearest_frontmatter_value(
|
|
211
|
+
sidecar_text, "task-type", match.start()
|
|
212
|
+
),
|
|
213
|
+
source_report=_nearest_frontmatter_value(
|
|
214
|
+
sidecar_text, "source-report", match.start()
|
|
215
|
+
),
|
|
216
|
+
seq=_nearest_frontmatter_value(sidecar_text, "seq", match.start()),
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _matching_analysis_review_sidecars(
|
|
221
|
+
report_path: Path,
|
|
222
|
+
sidecar_name: re.Pattern[str],
|
|
223
|
+
) -> tuple[Path, tuple[Path, ...]]:
|
|
224
|
+
run_dir = report_path.parent.parent
|
|
225
|
+
responses_dir = run_dir / "user-responses"
|
|
226
|
+
try:
|
|
227
|
+
responses_mode = responses_dir.lstat().st_mode
|
|
228
|
+
except FileNotFoundError:
|
|
229
|
+
return responses_dir, ()
|
|
230
|
+
except OSError as exc:
|
|
231
|
+
raise UserResponseError(
|
|
232
|
+
f"analysis user-responses directory is unreadable under {run_dir}"
|
|
233
|
+
) from exc
|
|
234
|
+
if not stat.S_ISDIR(responses_mode):
|
|
235
|
+
raise UserResponseError(
|
|
236
|
+
f"analysis user-responses must be a real directory under {run_dir}"
|
|
237
|
+
)
|
|
238
|
+
try:
|
|
239
|
+
resolved_run_dir = run_dir.resolve(strict=True)
|
|
240
|
+
resolved_responses_dir = responses_dir.resolve(strict=True)
|
|
241
|
+
except OSError as exc:
|
|
242
|
+
raise UserResponseError(
|
|
243
|
+
f"analysis user-responses directory is unreadable under {run_dir}"
|
|
244
|
+
) from exc
|
|
245
|
+
if resolved_responses_dir != resolved_run_dir / "user-responses":
|
|
246
|
+
raise UserResponseError(
|
|
247
|
+
f"analysis user-responses must stay under {resolved_run_dir}"
|
|
248
|
+
)
|
|
249
|
+
try:
|
|
250
|
+
sidecars = tuple(
|
|
251
|
+
sorted(
|
|
252
|
+
(
|
|
253
|
+
entry
|
|
254
|
+
for entry in responses_dir.iterdir()
|
|
255
|
+
if sidecar_name.fullmatch(entry.name)
|
|
256
|
+
),
|
|
257
|
+
key=lambda entry: entry.name,
|
|
258
|
+
)
|
|
259
|
+
)
|
|
260
|
+
except OSError as exc:
|
|
261
|
+
raise UserResponseError(
|
|
262
|
+
f"analysis user-responses directory is unreadable under {run_dir}"
|
|
263
|
+
) from exc
|
|
264
|
+
return responses_dir, sidecars
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _read_analysis_review_sidecar(sidecar: Path, responses_dir: Path) -> str:
|
|
268
|
+
try:
|
|
269
|
+
sidecar_mode = sidecar.lstat().st_mode
|
|
270
|
+
if not stat.S_ISREG(sidecar_mode):
|
|
271
|
+
raise OSError("not a regular file")
|
|
272
|
+
resolved = sidecar.resolve(strict=True)
|
|
273
|
+
if resolved.parent != responses_dir.resolve(strict=True):
|
|
274
|
+
raise OSError("outside user-responses")
|
|
275
|
+
return sidecar.read_text(encoding="utf-8")
|
|
276
|
+
except (OSError, UnicodeError) as exc:
|
|
277
|
+
raise UserResponseError(
|
|
278
|
+
"analysis review sidecar must be a readable regular file under "
|
|
279
|
+
f"{responses_dir}"
|
|
280
|
+
) from exc
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def _analysis_review_matches_with_valid_created_at(
|
|
284
|
+
sidecar_text: str,
|
|
285
|
+
) -> list[re.Match[str]]:
|
|
286
|
+
matches = list(_ANALYSIS_REVIEW_HEADING_RE.finditer(sidecar_text))
|
|
287
|
+
for match in matches:
|
|
288
|
+
created_at = _nearest_frontmatter_value(
|
|
289
|
+
sidecar_text, "created-at", match.start()
|
|
290
|
+
)
|
|
291
|
+
if _parsed_created_at(created_at) is None:
|
|
292
|
+
raise UserResponseError(
|
|
293
|
+
"ANALYSIS REVIEW requires a valid canonical created-at"
|
|
294
|
+
)
|
|
295
|
+
return matches
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def load_authoritative_analysis_review(
|
|
299
|
+
report_path: Path,
|
|
300
|
+
*,
|
|
301
|
+
expected_task_key: str,
|
|
302
|
+
expected_task_type: str,
|
|
303
|
+
) -> AnalysisReviewRecord | None:
|
|
304
|
+
"""Load the created-at-latest review attached to one analysis report."""
|
|
305
|
+
report_match = _ANALYSIS_REPORT_RE.fullmatch(report_path.name)
|
|
306
|
+
if report_match is None:
|
|
307
|
+
raise UserResponseError("analysis review source is not an analysis report")
|
|
308
|
+
sidecar_name = re.compile(
|
|
309
|
+
rf"^user-response-{re.escape(report_match.group('task_type'))}-"
|
|
310
|
+
rf"{re.escape(report_match.group('seq'))}(?:-.+)?\.md$"
|
|
311
|
+
)
|
|
312
|
+
responses_dir, sidecars = _matching_analysis_review_sidecars(
|
|
313
|
+
report_path, sidecar_name
|
|
314
|
+
)
|
|
315
|
+
if not sidecars:
|
|
316
|
+
return None
|
|
317
|
+
attached: list[str] = []
|
|
318
|
+
for sidecar in sidecars:
|
|
319
|
+
text = _read_analysis_review_sidecar(sidecar, responses_dir)
|
|
320
|
+
if _analysis_review_matches_with_valid_created_at(text):
|
|
321
|
+
attached.append(f"\n## {sidecar.name}\n\n{text.strip()}\n")
|
|
322
|
+
if not attached:
|
|
323
|
+
raise UserResponseError(
|
|
324
|
+
"existing review sidecar has no ANALYSIS REVIEW block"
|
|
325
|
+
)
|
|
326
|
+
review = parse_analysis_review("".join(attached))
|
|
327
|
+
if review is None:
|
|
328
|
+
raise UserResponseError("analysis review sidecar is unreadable")
|
|
329
|
+
expected_source = (
|
|
330
|
+
f"runs/{report_match.group('task_type')}/reports/{report_path.name}"
|
|
331
|
+
)
|
|
332
|
+
if review.task_key != expected_task_key:
|
|
333
|
+
raise UserResponseError(
|
|
334
|
+
"analysis review task-key does not match report taskKey"
|
|
335
|
+
)
|
|
336
|
+
if review.task_type != expected_task_type:
|
|
337
|
+
raise UserResponseError(
|
|
338
|
+
"analysis review task-type does not match report taskType"
|
|
339
|
+
)
|
|
340
|
+
if review.source_report != expected_source:
|
|
341
|
+
raise UserResponseError(
|
|
342
|
+
"analysis review source-report does not match report path"
|
|
343
|
+
)
|
|
344
|
+
if review.seq != report_match.group("seq"):
|
|
345
|
+
raise UserResponseError("analysis review seq does not match report runSeq")
|
|
346
|
+
return review
|
|
347
|
+
|
|
348
|
+
|
|
45
349
|
def parse_user_response_approval(
|
|
46
350
|
sidecar_text: str,
|
|
47
351
|
) -> Optional[UserResponseApprovalRecord]:
|
|
@@ -76,7 +380,9 @@ _RESPONSE_HEADING_RE = re.compile(r"^## (?P<id>[A-Za-z][A-Za-z0-9]*-\d+)\s*$", r
|
|
|
76
380
|
|
|
77
381
|
|
|
78
382
|
def _field(block: str, key: str) -> Optional[str]:
|
|
79
|
-
m = re.search(
|
|
383
|
+
m = re.search(
|
|
384
|
+
rf"^- {re.escape(key)}:[ \t]*(\S.*?)[ \t]*$", block, re.MULTILINE
|
|
385
|
+
)
|
|
80
386
|
return m.group(1) if m else None
|
|
81
387
|
|
|
82
388
|
|