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.
Files changed (40) hide show
  1. package/README.md +4 -1
  2. package/docs/architecture.md +18 -2
  3. package/docs/cli.md +39 -2
  4. package/docs/project-structure-overview.md +16 -4
  5. package/package.json +1 -1
  6. package/runtime/BUILD.json +2 -2
  7. package/runtime/prompts/lead/convergence.md +11 -3
  8. package/runtime/prompts/lead/okstra-lead-contract.md +7 -1
  9. package/runtime/prompts/profiles/_common-contract.md +1 -1
  10. package/runtime/prompts/profiles/change-impact-analysis.md +24 -0
  11. package/runtime/prompts/profiles/feature-analysis.md +24 -0
  12. package/runtime/prompts/profiles/forbidden-actions.json +18 -0
  13. package/runtime/prompts/profiles/project-analysis.md +24 -0
  14. package/runtime/prompts/wizard/prompts.ko.json +44 -1
  15. package/runtime/python/okstra_ctl/analysis_inputs.py +369 -0
  16. package/runtime/python/okstra_ctl/clarification_items.py +74 -1
  17. package/runtime/python/okstra_ctl/render.py +77 -4
  18. package/runtime/python/okstra_ctl/render_final_report.py +13 -4
  19. package/runtime/python/okstra_ctl/report_views.py +134 -3
  20. package/runtime/python/okstra_ctl/run.py +118 -0
  21. package/runtime/python/okstra_ctl/run_context.py +34 -2
  22. package/runtime/python/okstra_ctl/schema_excerpt.py +12 -4
  23. package/runtime/python/okstra_ctl/user_response.py +309 -3
  24. package/runtime/python/okstra_ctl/wizard.py +545 -32
  25. package/runtime/python/okstra_ctl/worker_prompt_policy.py +3 -0
  26. package/runtime/python/okstra_ctl/workflow.py +22 -0
  27. package/runtime/schemas/final-report-v1.0.schema.json +849 -3
  28. package/runtime/skills/okstra-run/SKILL.md +13 -1
  29. package/runtime/templates/reports/change-impact-analysis-input.template.md +58 -0
  30. package/runtime/templates/reports/feature-analysis-input.template.md +59 -0
  31. package/runtime/templates/reports/final-report.template.md +220 -0
  32. package/runtime/templates/reports/i18n/en.json +8 -0
  33. package/runtime/templates/reports/i18n/ko.json +8 -0
  34. package/runtime/templates/reports/project-analysis-input.template.md +58 -0
  35. package/runtime/templates/reports/report.js +84 -5
  36. package/runtime/templates/reports/user-response.template.md +19 -1
  37. package/runtime/validators/validate-report-views.py +61 -7
  38. package/runtime/validators/validate-run.py +42 -0
  39. package/runtime/validators/validate_analysis_report.py +864 -0
  40. 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 — the implementation wizard consumes it to offer
7
- "승인 + 옵션 적용" at the approve-confirm step.
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(rf"^- {re.escape(key)}:\s*(\S.*?)\s*$", block, re.MULTILINE)
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