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
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
"""Shared evidence resolution for read-only analysis sidetracks."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import re
|
|
6
|
+
import subprocess
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from datetime import datetime
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Mapping, Sequence
|
|
11
|
+
|
|
12
|
+
from .final_report_paths import final_report_data_path
|
|
13
|
+
from .paths import task_manifest_file
|
|
14
|
+
from .user_response import (
|
|
15
|
+
UserResponseError,
|
|
16
|
+
load_authoritative_analysis_review,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
ANALYSIS_TASK_TYPES = (
|
|
21
|
+
"project-analysis",
|
|
22
|
+
"feature-analysis",
|
|
23
|
+
"change-impact-analysis",
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class AnalysisInputError(ValueError):
|
|
28
|
+
"""Raised when a report cannot safely serve as analysis evidence."""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True)
|
|
32
|
+
class AnalysisReportCandidate:
|
|
33
|
+
report_path: Path
|
|
34
|
+
task_key: str
|
|
35
|
+
task_type: str
|
|
36
|
+
created_at: str
|
|
37
|
+
run_seq: str
|
|
38
|
+
source_commit: str
|
|
39
|
+
review_status: str
|
|
40
|
+
feature_index: tuple[object, ...]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True)
|
|
44
|
+
class ResolvedEvidenceInput:
|
|
45
|
+
report_path: Path
|
|
46
|
+
task_key: str
|
|
47
|
+
task_type: str
|
|
48
|
+
run_seq: str
|
|
49
|
+
source_commit: str
|
|
50
|
+
review_status: str
|
|
51
|
+
relation: str
|
|
52
|
+
freshness: str
|
|
53
|
+
|
|
54
|
+
def to_dict(self) -> dict[str, str]:
|
|
55
|
+
return {
|
|
56
|
+
"taskKey": self.task_key,
|
|
57
|
+
"taskType": self.task_type,
|
|
58
|
+
"reportPath": str(self.report_path),
|
|
59
|
+
"runSeq": self.run_seq,
|
|
60
|
+
"sourceCommit": self.source_commit,
|
|
61
|
+
"relation": self.relation,
|
|
62
|
+
"freshness": self.freshness,
|
|
63
|
+
"reviewStatus": self.review_status,
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
_EVIDENCE_RELATION_BY_TYPES = {
|
|
68
|
+
("feature-analysis", "project-analysis"): "project-context",
|
|
69
|
+
("change-impact-analysis", "project-analysis"): "project-context",
|
|
70
|
+
("change-impact-analysis", "feature-analysis"): "feature-baseline",
|
|
71
|
+
}
|
|
72
|
+
_REPORT_NAME_RE = re.compile(r"^final-report-.+-(?P<run_seq>[^-]+)\.md$")
|
|
73
|
+
_FEATURE_ID_RE = re.compile(r"PF-\d{3}")
|
|
74
|
+
_FULL_COMMIT_RE = re.compile(r"[0-9a-f]{40}")
|
|
75
|
+
_RUN_SEQ_RE = re.compile(r"[0-9]{3}")
|
|
76
|
+
_CREATED_AT_RE = re.compile(r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z")
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def parse_evidence_paths(
|
|
80
|
+
raw_paths: str, project_root: Path | None = None,
|
|
81
|
+
) -> tuple[Path, ...]:
|
|
82
|
+
"""Parse a comma-separated evidence selection in its supplied order."""
|
|
83
|
+
root = project_root.resolve() if project_root else None
|
|
84
|
+
paths: list[Path] = []
|
|
85
|
+
for raw_path in raw_paths.split(","):
|
|
86
|
+
value = raw_path.strip()
|
|
87
|
+
if not value:
|
|
88
|
+
continue
|
|
89
|
+
path = Path(value).expanduser()
|
|
90
|
+
if root and not path.is_absolute():
|
|
91
|
+
path = root / path
|
|
92
|
+
paths.append(path.resolve())
|
|
93
|
+
return tuple(paths)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def load_candidate_map(
|
|
97
|
+
project_root: Path, report_paths: Sequence[Path],
|
|
98
|
+
) -> dict[Path, AnalysisReportCandidate]:
|
|
99
|
+
"""Load canonical metadata for every explicitly selected report."""
|
|
100
|
+
return {
|
|
101
|
+
candidate.report_path: candidate
|
|
102
|
+
for candidate in (
|
|
103
|
+
load_analysis_report_candidate(project_root, report_path)
|
|
104
|
+
for report_path in report_paths
|
|
105
|
+
)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def resolve_analysis_head(analysis_root: Path, task_type: str = "") -> str:
|
|
110
|
+
"""Return an analysis worktree's immutable HEAD, or reject an invalid one."""
|
|
111
|
+
if task_type and task_type not in ANALYSIS_TASK_TYPES:
|
|
112
|
+
return ""
|
|
113
|
+
try:
|
|
114
|
+
result = subprocess.run(
|
|
115
|
+
["git", "-C", str(analysis_root), "rev-parse", "HEAD"],
|
|
116
|
+
check=True,
|
|
117
|
+
capture_output=True,
|
|
118
|
+
text=True,
|
|
119
|
+
)
|
|
120
|
+
except (OSError, subprocess.CalledProcessError) as exc:
|
|
121
|
+
raise AnalysisInputError(
|
|
122
|
+
f"cannot resolve analysis source commit: {analysis_root}"
|
|
123
|
+
) from exc
|
|
124
|
+
return _validated_commit(result.stdout.strip(), "analysis source commit")
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _resolved_within(path: Path, root: Path, description: str) -> Path:
|
|
128
|
+
resolved = path.resolve()
|
|
129
|
+
try:
|
|
130
|
+
resolved.relative_to(root)
|
|
131
|
+
except ValueError as exc:
|
|
132
|
+
raise AnalysisInputError(f"{description} must be under {root}") from exc
|
|
133
|
+
return resolved
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _required_string(data: Mapping[str, object], key: str, context: str) -> str:
|
|
137
|
+
value = data.get(key)
|
|
138
|
+
if not isinstance(value, str) or not value:
|
|
139
|
+
raise AnalysisInputError(f"data.json {context}.{key} must be a non-empty string")
|
|
140
|
+
return value
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _validated_commit(value: str, field: str) -> str:
|
|
144
|
+
if not _FULL_COMMIT_RE.fullmatch(value):
|
|
145
|
+
raise AnalysisInputError(
|
|
146
|
+
f"{field} must be a 40-character lowercase hexadecimal commit"
|
|
147
|
+
)
|
|
148
|
+
return value
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _validated_created_at(value: str) -> str:
|
|
152
|
+
if _CREATED_AT_RE.fullmatch(value) is None:
|
|
153
|
+
raise AnalysisInputError(
|
|
154
|
+
"data.json header.createdAt must use YYYY-MM-DDTHH:MM:SSZ"
|
|
155
|
+
)
|
|
156
|
+
try:
|
|
157
|
+
datetime.strptime(value, "%Y-%m-%dT%H:%M:%SZ")
|
|
158
|
+
except ValueError as exc:
|
|
159
|
+
raise AnalysisInputError("data.json header.createdAt is invalid") from exc
|
|
160
|
+
return value
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _report_path_metadata(project_root: Path, report_path: Path) -> tuple[Path, str, str]:
|
|
164
|
+
okstra_root = project_root.resolve() / ".okstra"
|
|
165
|
+
relative = report_path.relative_to(okstra_root)
|
|
166
|
+
parts = relative.parts
|
|
167
|
+
if (
|
|
168
|
+
len(parts) != 7
|
|
169
|
+
or parts[0] != "tasks"
|
|
170
|
+
or parts[3] != "runs"
|
|
171
|
+
or parts[5] != "reports"
|
|
172
|
+
):
|
|
173
|
+
raise AnalysisInputError("report path is not an analysis run report")
|
|
174
|
+
return report_path.parents[3], parts[4], parts[6]
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _task_key_from_manifest(task_root: Path, okstra_root: Path) -> str:
|
|
178
|
+
manifest_path = _resolved_within(
|
|
179
|
+
task_manifest_file(task_root), okstra_root, "task manifest path"
|
|
180
|
+
)
|
|
181
|
+
try:
|
|
182
|
+
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
|
183
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
184
|
+
raise AnalysisInputError(f"cannot read task manifest: {manifest_path}") from exc
|
|
185
|
+
if not isinstance(manifest, dict):
|
|
186
|
+
raise AnalysisInputError("task manifest must contain an object")
|
|
187
|
+
return _required_string(manifest, "taskKey", "task manifest")
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def load_analysis_report_candidate(
|
|
191
|
+
project_root: Path, report_path: Path
|
|
192
|
+
) -> AnalysisReportCandidate:
|
|
193
|
+
"""Load one report only after validating its path and canonical metadata."""
|
|
194
|
+
root = project_root.resolve()
|
|
195
|
+
okstra_root = root / ".okstra"
|
|
196
|
+
report = _resolved_within(report_path, okstra_root, "report path")
|
|
197
|
+
if not report.is_file():
|
|
198
|
+
raise AnalysisInputError(f"report does not exist: {report}")
|
|
199
|
+
task_root, path_task_type, filename = _report_path_metadata(root, report)
|
|
200
|
+
filename_match = _REPORT_NAME_RE.fullmatch(filename)
|
|
201
|
+
if not filename_match:
|
|
202
|
+
raise AnalysisInputError("report filename is not a final report")
|
|
203
|
+
data_path = _resolved_within(final_report_data_path(report), okstra_root, "data.json path")
|
|
204
|
+
try:
|
|
205
|
+
data = json.loads(data_path.read_text(encoding="utf-8"))
|
|
206
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
207
|
+
raise AnalysisInputError(f"cannot read data.json: {data_path}") from exc
|
|
208
|
+
if not isinstance(data, dict):
|
|
209
|
+
raise AnalysisInputError("data.json must contain an object")
|
|
210
|
+
header = data.get("header")
|
|
211
|
+
common = data.get("analysisCommon")
|
|
212
|
+
if not isinstance(header, dict) or not isinstance(common, dict):
|
|
213
|
+
raise AnalysisInputError("data.json requires header and analysisCommon objects")
|
|
214
|
+
task_key = _required_string(header, "taskKey", "header")
|
|
215
|
+
task_type = _required_string(header, "taskType", "header")
|
|
216
|
+
created_at = _validated_created_at(
|
|
217
|
+
_required_string(header, "createdAt", "header")
|
|
218
|
+
)
|
|
219
|
+
run_seq = _required_string(common, "runSeq", "analysisCommon")
|
|
220
|
+
if not _RUN_SEQ_RE.fullmatch(run_seq):
|
|
221
|
+
raise AnalysisInputError("data.json analysisCommon.runSeq must be 3-digit")
|
|
222
|
+
source_commit = _validated_commit(
|
|
223
|
+
_required_string(common, "sourceCommit", "analysisCommon"),
|
|
224
|
+
"sourceCommit",
|
|
225
|
+
)
|
|
226
|
+
if task_key != _task_key_from_manifest(task_root, okstra_root):
|
|
227
|
+
raise AnalysisInputError("data.json header.taskKey does not match task manifest")
|
|
228
|
+
if task_type != path_task_type:
|
|
229
|
+
raise AnalysisInputError("data.json header.taskType does not match report path")
|
|
230
|
+
if filename != f"final-report-{task_type}-{run_seq}.md":
|
|
231
|
+
raise AnalysisInputError("data.json analysisCommon.runSeq does not match report filename")
|
|
232
|
+
feature_index: Sequence[object] = ()
|
|
233
|
+
if task_type == "project-analysis":
|
|
234
|
+
project_analysis = data.get("projectAnalysis")
|
|
235
|
+
if not isinstance(project_analysis, dict):
|
|
236
|
+
raise AnalysisInputError("data.json projectAnalysis must be an object")
|
|
237
|
+
feature_index = project_analysis.get("featureIndex")
|
|
238
|
+
if not isinstance(feature_index, list):
|
|
239
|
+
raise AnalysisInputError(
|
|
240
|
+
"data.json projectAnalysis.featureIndex must be a list"
|
|
241
|
+
)
|
|
242
|
+
try:
|
|
243
|
+
review = load_authoritative_analysis_review(
|
|
244
|
+
report,
|
|
245
|
+
expected_task_key=task_key,
|
|
246
|
+
expected_task_type=task_type,
|
|
247
|
+
)
|
|
248
|
+
except UserResponseError as exc:
|
|
249
|
+
raise AnalysisInputError(f"invalid review sidecar: {exc}") from exc
|
|
250
|
+
review_status = review.status if review is not None else "unreviewed"
|
|
251
|
+
return AnalysisReportCandidate(
|
|
252
|
+
report_path=report,
|
|
253
|
+
task_key=task_key,
|
|
254
|
+
task_type=task_type,
|
|
255
|
+
created_at=created_at,
|
|
256
|
+
run_seq=run_seq,
|
|
257
|
+
source_commit=source_commit,
|
|
258
|
+
review_status=review_status,
|
|
259
|
+
feature_index=tuple(feature_index),
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def list_evidence_candidates(
|
|
264
|
+
project_root: Path, consumer_task_type: str, relation: str
|
|
265
|
+
) -> list[AnalysisReportCandidate]:
|
|
266
|
+
"""Return accepted, compatible reports in deterministic newest-first order."""
|
|
267
|
+
root = project_root.resolve()
|
|
268
|
+
compatible_types = [
|
|
269
|
+
source_type
|
|
270
|
+
for (consumer, source_type), allowed_relation in _EVIDENCE_RELATION_BY_TYPES.items()
|
|
271
|
+
if consumer == consumer_task_type and allowed_relation == relation
|
|
272
|
+
]
|
|
273
|
+
if not compatible_types:
|
|
274
|
+
return []
|
|
275
|
+
reports_root = root / ".okstra" / "tasks"
|
|
276
|
+
candidates: list[AnalysisReportCandidate] = []
|
|
277
|
+
for report in reports_root.glob("*/*/runs/*/reports/*.md"):
|
|
278
|
+
try:
|
|
279
|
+
candidate = load_analysis_report_candidate(root, report)
|
|
280
|
+
except AnalysisInputError:
|
|
281
|
+
continue
|
|
282
|
+
if candidate.task_type in compatible_types and candidate.review_status == "accepted":
|
|
283
|
+
candidates.append(candidate)
|
|
284
|
+
candidates.sort(
|
|
285
|
+
key=lambda candidate: (
|
|
286
|
+
candidate.task_key,
|
|
287
|
+
-int(candidate.run_seq),
|
|
288
|
+
str(candidate.report_path),
|
|
289
|
+
)
|
|
290
|
+
)
|
|
291
|
+
candidates.sort(key=lambda candidate: candidate.created_at, reverse=True)
|
|
292
|
+
return candidates
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def resolve_evidence_inputs(
|
|
296
|
+
project_root: Path,
|
|
297
|
+
consumer_task_type: str,
|
|
298
|
+
report_paths: Sequence[Path],
|
|
299
|
+
current_commit: str,
|
|
300
|
+
) -> tuple[ResolvedEvidenceInput, ...]:
|
|
301
|
+
"""Validate the explicit evidence selection that a consumer will use."""
|
|
302
|
+
_validated_commit(current_commit, "current_commit")
|
|
303
|
+
resolved: list[ResolvedEvidenceInput] = []
|
|
304
|
+
seen: set[Path] = set()
|
|
305
|
+
for report_path in report_paths:
|
|
306
|
+
candidate = load_analysis_report_candidate(project_root, report_path)
|
|
307
|
+
if candidate.report_path in seen:
|
|
308
|
+
raise AnalysisInputError(f"duplicate evidence report: {candidate.report_path}")
|
|
309
|
+
seen.add(candidate.report_path)
|
|
310
|
+
relation = _EVIDENCE_RELATION_BY_TYPES.get(
|
|
311
|
+
(consumer_task_type, candidate.task_type)
|
|
312
|
+
)
|
|
313
|
+
if relation is None:
|
|
314
|
+
raise AnalysisInputError(
|
|
315
|
+
f"evidence relation is not allowed: {consumer_task_type} <- {candidate.task_type}"
|
|
316
|
+
)
|
|
317
|
+
if candidate.review_status in {"revision-requested", "rejected"}:
|
|
318
|
+
raise AnalysisInputError(
|
|
319
|
+
f"evidence report has {candidate.review_status} review status"
|
|
320
|
+
)
|
|
321
|
+
resolved.append(ResolvedEvidenceInput(
|
|
322
|
+
report_path=candidate.report_path,
|
|
323
|
+
task_key=candidate.task_key,
|
|
324
|
+
task_type=candidate.task_type,
|
|
325
|
+
run_seq=candidate.run_seq,
|
|
326
|
+
source_commit=candidate.source_commit,
|
|
327
|
+
review_status=("accepted" if candidate.review_status == "accepted" else "user-unverified"),
|
|
328
|
+
relation=relation,
|
|
329
|
+
freshness="exact" if candidate.source_commit == current_commit else "stale",
|
|
330
|
+
))
|
|
331
|
+
return tuple(resolved)
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def resolve_analysis_target(
|
|
335
|
+
raw_target: str,
|
|
336
|
+
evidence_inputs: Sequence[ResolvedEvidenceInput],
|
|
337
|
+
candidates: Mapping[Path, AnalysisReportCandidate],
|
|
338
|
+
) -> dict[str, object]:
|
|
339
|
+
"""Normalize a feature-index identifier or a non-empty free-text target."""
|
|
340
|
+
target = raw_target.strip()
|
|
341
|
+
if not target:
|
|
342
|
+
raise AnalysisInputError("analysis target must not be empty")
|
|
343
|
+
if not _FEATURE_ID_RE.fullmatch(target):
|
|
344
|
+
return {"inputMode": "free-text", "requestedValue": target}
|
|
345
|
+
matches: list[dict[object, object]] = []
|
|
346
|
+
for evidence in evidence_inputs:
|
|
347
|
+
if evidence.relation != "project-context":
|
|
348
|
+
continue
|
|
349
|
+
candidate = candidates.get(evidence.report_path)
|
|
350
|
+
if candidate is None:
|
|
351
|
+
candidate = next(
|
|
352
|
+
(value for path, value in candidates.items() if path.resolve() == evidence.report_path),
|
|
353
|
+
None,
|
|
354
|
+
)
|
|
355
|
+
if candidate is None:
|
|
356
|
+
continue
|
|
357
|
+
matches.extend(
|
|
358
|
+
feature for feature in candidate.feature_index
|
|
359
|
+
if isinstance(feature, dict) and feature.get("id") == target
|
|
360
|
+
)
|
|
361
|
+
if len(matches) != 1:
|
|
362
|
+
raise AnalysisInputError(
|
|
363
|
+
f"exactly one project-context feature must match {target}"
|
|
364
|
+
)
|
|
365
|
+
return {
|
|
366
|
+
"inputMode": "feature-index",
|
|
367
|
+
"requestedValue": target,
|
|
368
|
+
"feature": dict(matches[0]),
|
|
369
|
+
}
|
|
@@ -372,6 +372,79 @@ def user_response_sidecars(source: Path) -> list[Path]:
|
|
|
372
372
|
)
|
|
373
373
|
|
|
374
374
|
|
|
375
|
+
_ANALYSIS_REPORT_NAME_RE = re.compile(
|
|
376
|
+
r"^final-report-(?P<task_type>project-analysis|feature-analysis|"
|
|
377
|
+
r"change-impact-analysis)-(?P<seq>\d{3})\.md$"
|
|
378
|
+
)
|
|
379
|
+
_SIDECAR_FRONTMATTER_RE = re.compile(
|
|
380
|
+
r"\A---[ \t]*\r?\n(?P<body>.*?)(?:\r?\n)---[ \t]*(?:\r?\n|\Z)",
|
|
381
|
+
re.DOTALL,
|
|
382
|
+
)
|
|
383
|
+
_ANALYSIS_REVIEW_HEADING_RE = re.compile(
|
|
384
|
+
r"^## ANALYSIS REVIEW\s*$", re.MULTILINE
|
|
385
|
+
)
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def _sidecar_frontmatter_value(text: str, key: str) -> str:
|
|
389
|
+
match = _SIDECAR_FRONTMATTER_RE.match(text)
|
|
390
|
+
if match is None:
|
|
391
|
+
return ""
|
|
392
|
+
value = re.search(
|
|
393
|
+
rf"^{re.escape(key)}:\s*(\S.*?)\s*$",
|
|
394
|
+
match.group("body"),
|
|
395
|
+
re.MULTILINE,
|
|
396
|
+
)
|
|
397
|
+
return value.group(1) if value else ""
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def _sidecars_for_attachment(source: Path) -> list[Path]:
|
|
401
|
+
sidecars = user_response_sidecars(source)
|
|
402
|
+
report_match = _ANALYSIS_REPORT_NAME_RE.fullmatch(source.name)
|
|
403
|
+
if report_match is None:
|
|
404
|
+
return sidecars
|
|
405
|
+
expected_source = (
|
|
406
|
+
f"runs/{report_match.group('task_type')}/reports/{source.name}"
|
|
407
|
+
)
|
|
408
|
+
ordinary: list[Path] = []
|
|
409
|
+
candidates: list[tuple[Path, str]] = []
|
|
410
|
+
for sidecar in sidecars:
|
|
411
|
+
try:
|
|
412
|
+
text = sidecar.read_text(encoding="utf-8")
|
|
413
|
+
except OSError:
|
|
414
|
+
ordinary.append(sidecar)
|
|
415
|
+
continue
|
|
416
|
+
if _ANALYSIS_REVIEW_HEADING_RE.search(text) is None:
|
|
417
|
+
ordinary.append(sidecar)
|
|
418
|
+
continue
|
|
419
|
+
source_report = _sidecar_frontmatter_value(text, "source-report")
|
|
420
|
+
seq = _sidecar_frontmatter_value(text, "seq")
|
|
421
|
+
if source_report and source_report != expected_source:
|
|
422
|
+
continue
|
|
423
|
+
if seq and seq != report_match.group("seq"):
|
|
424
|
+
continue
|
|
425
|
+
candidates.append((sidecar, text))
|
|
426
|
+
|
|
427
|
+
from okstra_ctl.user_response import UserResponseError, parse_analysis_review
|
|
428
|
+
|
|
429
|
+
valid: list[Path] = []
|
|
430
|
+
malformed: list[Path] = []
|
|
431
|
+
for sidecar, text in candidates:
|
|
432
|
+
try:
|
|
433
|
+
review = parse_analysis_review(text)
|
|
434
|
+
except UserResponseError:
|
|
435
|
+
malformed.append(sidecar)
|
|
436
|
+
continue
|
|
437
|
+
if review is not None and (
|
|
438
|
+
review.source_report == expected_source
|
|
439
|
+
and review.seq == report_match.group("seq")
|
|
440
|
+
):
|
|
441
|
+
valid.append(sidecar)
|
|
442
|
+
else:
|
|
443
|
+
malformed.append(sidecar)
|
|
444
|
+
selected = valid if valid else malformed
|
|
445
|
+
return sorted([*ordinary, *selected])
|
|
446
|
+
|
|
447
|
+
|
|
375
448
|
def _sidecar_answers(source: Path) -> dict[str, str]:
|
|
376
449
|
"""`user-responses/` 사이드카들의 답변을 `{clarification-id: value}` 로 모은다.
|
|
377
450
|
|
|
@@ -399,7 +472,7 @@ def attached_user_responses_section(source: Path) -> str:
|
|
|
399
472
|
첨부할 때 쓴다. `clarification_response_with_sidecars` 와 같은 직렬화 포맷을
|
|
400
473
|
한 곳에서 만들어 두 carry-in 경로가 갈라지지 않게 한다.
|
|
401
474
|
"""
|
|
402
|
-
sidecars =
|
|
475
|
+
sidecars = _sidecars_for_attachment(source)
|
|
403
476
|
if not sidecars:
|
|
404
477
|
return ""
|
|
405
478
|
parts = ["# Attached User Responses\n"]
|
|
@@ -29,6 +29,7 @@ from okstra_project.dirs import TASK_MANIFEST_FILENAME, OKSTRA_DIR_NAME, project
|
|
|
29
29
|
# render_task_manifest 가 동일한 리스트/딕셔너리를 로컬에 중복 정의했는데,
|
|
30
30
|
# 이는 silent drift 위험이 있어 SSOT import 로 통합한다.
|
|
31
31
|
from . import fix_cycles
|
|
32
|
+
from .analysis_inputs import ANALYSIS_TASK_TYPES
|
|
32
33
|
from .paths import okstra_home
|
|
33
34
|
from .lead_runtime import lead_runtime_info
|
|
34
35
|
from .path_hints import compact_active_run_context, hydrate_run_context
|
|
@@ -874,6 +875,45 @@ def _required_worker_roles(ctx: dict, reviewers: list[str]) -> list[dict]:
|
|
|
874
875
|
]
|
|
875
876
|
|
|
876
877
|
|
|
878
|
+
def _reporter_confirmation_status(brief_bytes: bytes) -> str:
|
|
879
|
+
try:
|
|
880
|
+
lines = brief_bytes.decode("utf-8").splitlines()
|
|
881
|
+
except UnicodeDecodeError:
|
|
882
|
+
return ""
|
|
883
|
+
if not lines or lines[0].strip() != "---":
|
|
884
|
+
return ""
|
|
885
|
+
for line in lines[1:]:
|
|
886
|
+
if line.strip() == "---":
|
|
887
|
+
break
|
|
888
|
+
key, separator, value = line.partition(":")
|
|
889
|
+
if separator and key.strip() == "reporter-confirmations":
|
|
890
|
+
return value.strip().strip("'\"").lower()
|
|
891
|
+
return ""
|
|
892
|
+
|
|
893
|
+
|
|
894
|
+
def _analysis_scope_confirmation_snapshot(ctx: dict) -> dict | None:
|
|
895
|
+
if ctx.get("TASK_TYPE") not in ANALYSIS_TASK_TYPES:
|
|
896
|
+
return None
|
|
897
|
+
brief_path = Path(ctx.get("BRIEF_FILE_PATH", ""))
|
|
898
|
+
try:
|
|
899
|
+
brief_bytes = brief_path.read_bytes() if brief_path.is_file() else None
|
|
900
|
+
except OSError:
|
|
901
|
+
brief_bytes = None
|
|
902
|
+
return {
|
|
903
|
+
"taskBriefPath": ctx.get("BRIEF_RELATIVE_PATH", ""),
|
|
904
|
+
"status": (
|
|
905
|
+
_reporter_confirmation_status(brief_bytes)
|
|
906
|
+
if brief_bytes is not None
|
|
907
|
+
else ""
|
|
908
|
+
),
|
|
909
|
+
"briefSha256": (
|
|
910
|
+
hashlib.sha256(brief_bytes).hexdigest()
|
|
911
|
+
if brief_bytes is not None
|
|
912
|
+
else ""
|
|
913
|
+
),
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
|
|
877
917
|
def _derive_phase_states(existing_workflow: dict, ctx: dict) -> tuple[dict, str, str]:
|
|
878
918
|
"""phaseStates dict + (current_phase, current_phase_state) 를 도출한다.
|
|
879
919
|
|
|
@@ -1204,9 +1244,8 @@ def _build_convergence_block(ctx: dict) -> dict:
|
|
|
1204
1244
|
- `enabled` default True
|
|
1205
1245
|
- `maxRounds` default 1 for `requirements-discovery`, 2 otherwise
|
|
1206
1246
|
- `verificationMode` default "lightweight"
|
|
1207
|
-
- `adversarial` default True for
|
|
1208
|
-
|
|
1209
|
-
False otherwise
|
|
1247
|
+
- `adversarial` default True for discovery, planning, and analysis task types
|
|
1248
|
+
(forces `verificationMode` to "full-reanalysis"), False otherwise
|
|
1210
1249
|
- `planBodyVerification` is implementation-planning specific; the key is
|
|
1211
1250
|
always emitted (dead-letter on other phases) so the schema stays stable.
|
|
1212
1251
|
Its `selfFixMaxRounds` default 3 bounds the report-writer self-fix loop
|
|
@@ -1222,7 +1261,14 @@ def _build_convergence_block(ctx: dict) -> dict:
|
|
|
1222
1261
|
"""
|
|
1223
1262
|
task_type = ctx.get("TASK_TYPE", "")
|
|
1224
1263
|
default_max_rounds = 1 if task_type == "requirements-discovery" else 2
|
|
1225
|
-
adversarial_phases = {
|
|
1264
|
+
adversarial_phases = {
|
|
1265
|
+
"requirements-discovery",
|
|
1266
|
+
"error-analysis",
|
|
1267
|
+
"implementation-planning",
|
|
1268
|
+
"project-analysis",
|
|
1269
|
+
"feature-analysis",
|
|
1270
|
+
"change-impact-analysis",
|
|
1271
|
+
}
|
|
1226
1272
|
is_adversarial = task_type in adversarial_phases
|
|
1227
1273
|
raw_plan_verify = (ctx.get("OKSTRA_PLAN_VERIFICATION", "") or "").strip().lower()
|
|
1228
1274
|
plan_verify_enabled = raw_plan_verify != "false"
|
|
@@ -1257,6 +1303,20 @@ def _build_convergence_block(ctx: dict) -> dict:
|
|
|
1257
1303
|
|
|
1258
1304
|
|
|
1259
1305
|
def render_run_manifest(run_manifest_path: str, ctx: dict) -> None:
|
|
1306
|
+
run_manifest_file = Path(run_manifest_path)
|
|
1307
|
+
existing_run_manifest = {}
|
|
1308
|
+
if run_manifest_file.is_file():
|
|
1309
|
+
try:
|
|
1310
|
+
loaded_run_manifest = json.loads(
|
|
1311
|
+
run_manifest_file.read_text(encoding="utf-8")
|
|
1312
|
+
)
|
|
1313
|
+
existing_run_manifest = (
|
|
1314
|
+
loaded_run_manifest
|
|
1315
|
+
if isinstance(loaded_run_manifest, dict)
|
|
1316
|
+
else {}
|
|
1317
|
+
)
|
|
1318
|
+
except (OSError, json.JSONDecodeError):
|
|
1319
|
+
existing_run_manifest = {}
|
|
1260
1320
|
task_manifest_path = Path(ctx.get("TASK_MANIFEST_PATH", ""))
|
|
1261
1321
|
task_manifest = {}
|
|
1262
1322
|
if task_manifest_path.exists():
|
|
@@ -1330,6 +1390,14 @@ def render_run_manifest(run_manifest_path: str, ctx: dict) -> None:
|
|
|
1330
1390
|
"teamStatePath": ctx.get("TEAM_STATE_RELATIVE_PATH", ""),
|
|
1331
1391
|
"activeRunContextPath": ctx.get("ACTIVE_RUN_CONTEXT_RELATIVE_PATH", ""),
|
|
1332
1392
|
"analysisPacketPath": ctx.get("ANALYSIS_PACKET_RELATIVE_PATH", ""),
|
|
1393
|
+
"analysisEvidencePath": (
|
|
1394
|
+
ctx.get("INSTRUCTION_SET_RELATIVE_PATH", "") + "/analysis-evidence.md"
|
|
1395
|
+
if ctx.get("TASK_TYPE") in {"project-analysis", "feature-analysis", "change-impact-analysis"}
|
|
1396
|
+
else ""
|
|
1397
|
+
),
|
|
1398
|
+
"analysisSourceCommit": ctx.get("ANALYSIS_SOURCE_COMMIT", ""),
|
|
1399
|
+
"analysisTarget": json.loads(ctx.get("ANALYSIS_TARGET_JSON", "{}")),
|
|
1400
|
+
"evidenceInputs": json.loads(ctx.get("EVIDENCE_INPUTS_JSON", "[]")),
|
|
1333
1401
|
"verificationTargetPath": ctx.get("VERIFICATION_TARGET_RELATIVE_PATH", ""),
|
|
1334
1402
|
"verificationTargetDigest": ctx.get("VERIFICATION_TARGET_DIGEST", ""),
|
|
1335
1403
|
"workerResultsDirectoryPath": ctx.get("WORKER_RESULTS_RELATIVE_PATH", ""),
|
|
@@ -1403,6 +1471,11 @@ def render_run_manifest(run_manifest_path: str, ctx: dict) -> None:
|
|
|
1403
1471
|
"renderOnly": ctx.get("RENDER_ONLY", ""),
|
|
1404
1472
|
"createdAt": ctx.get("RUN_TIMESTAMP_ISO", ""),
|
|
1405
1473
|
}
|
|
1474
|
+
scope_confirmation = existing_run_manifest.get("analysisScopeConfirmation")
|
|
1475
|
+
if "analysisScopeConfirmation" not in existing_run_manifest:
|
|
1476
|
+
scope_confirmation = _analysis_scope_confirmation_snapshot(ctx)
|
|
1477
|
+
if scope_confirmation is not None:
|
|
1478
|
+
payload["analysisScopeConfirmation"] = scope_confirmation
|
|
1406
1479
|
if ctx.get("FIX_CYCLE_ID"):
|
|
1407
1480
|
payload["fixCycleId"] = ctx["FIX_CYCLE_ID"]
|
|
1408
1481
|
payload["reportContracts"] = (
|
|
@@ -67,7 +67,7 @@ class FinalReportRenderError(RuntimeError):
|
|
|
67
67
|
|
|
68
68
|
|
|
69
69
|
def _format_int(value: Any) -> str:
|
|
70
|
-
if value is None:
|
|
70
|
+
if value is None or not isinstance(value, (str, int, float)):
|
|
71
71
|
return "--"
|
|
72
72
|
try:
|
|
73
73
|
return f"{int(value):,}"
|
|
@@ -76,7 +76,7 @@ def _format_int(value: Any) -> str:
|
|
|
76
76
|
|
|
77
77
|
|
|
78
78
|
def _format_usd(value: Any) -> str:
|
|
79
|
-
if value is None:
|
|
79
|
+
if value is None or not isinstance(value, (str, int, float)):
|
|
80
80
|
return "--"
|
|
81
81
|
try:
|
|
82
82
|
return f"${float(value):.2f}"
|
|
@@ -85,7 +85,7 @@ def _format_usd(value: Any) -> str:
|
|
|
85
85
|
|
|
86
86
|
|
|
87
87
|
def _format_duration_ms(value: Any) -> str:
|
|
88
|
-
if value is None:
|
|
88
|
+
if value is None or not isinstance(value, (str, int, float)):
|
|
89
89
|
return "--"
|
|
90
90
|
try:
|
|
91
91
|
ms = int(value)
|
|
@@ -583,10 +583,16 @@ def resolve_report_language(data: dict, *, override: str | None) -> str:
|
|
|
583
583
|
# through, `| length` raises on it, and both `x` and `not x` evaluate true. So a
|
|
584
584
|
# template cannot test for absence at all, and the value has to arrive filled.
|
|
585
585
|
_OPTIONAL_ARRAY_DEFAULTS = ("endStateCoverage",)
|
|
586
|
+
_OPTIONAL_ANALYSIS_DEFAULTS = (
|
|
587
|
+
"analysisCommon",
|
|
588
|
+
"projectAnalysis",
|
|
589
|
+
"featureAnalysis",
|
|
590
|
+
"changeImpactAnalysis",
|
|
591
|
+
)
|
|
586
592
|
|
|
587
593
|
|
|
588
594
|
def _with_optional_defaults(data: dict) -> dict:
|
|
589
|
-
"""Render context with schema-optional
|
|
595
|
+
"""Render context with schema-optional fields filled in.
|
|
590
596
|
|
|
591
597
|
Keeps the schema field optional — an omitted `endStateCoverage` stays absent
|
|
592
598
|
in data.json, which is what the run validator reads to tell a legacy brief
|
|
@@ -597,6 +603,9 @@ def _with_optional_defaults(data: dict) -> dict:
|
|
|
597
603
|
for key in _OPTIONAL_ARRAY_DEFAULTS:
|
|
598
604
|
if not isinstance(filled.get(key), list):
|
|
599
605
|
filled[key] = []
|
|
606
|
+
for key in _OPTIONAL_ANALYSIS_DEFAULTS:
|
|
607
|
+
if not isinstance(filled.get(key), dict):
|
|
608
|
+
filled[key] = None
|
|
600
609
|
return filled
|
|
601
610
|
|
|
602
611
|
|