okstra 0.144.0 → 0.146.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 +22 -4
- package/docs/cli.md +53 -6
- package/docs/project-structure-overview.md +19 -9
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/agents/workers/report-writer-worker.md +5 -6
- package/runtime/bin/okstra-trace-cleanup.sh +28 -2
- package/runtime/prompts/lead/adapters/claude-code.md +3 -3
- package/runtime/prompts/lead/convergence.md +30 -5
- package/runtime/prompts/lead/okstra-lead-contract.md +9 -3
- package/runtime/prompts/lead/report-writer.md +20 -14
- package/runtime/prompts/lead/team-contract.md +3 -3
- 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/analysis_packet.py +4 -10
- package/runtime/python/okstra_ctl/clarification_items.py +74 -1
- package/runtime/python/okstra_ctl/codex_dispatch.py +117 -58
- package/runtime/python/okstra_ctl/convergence_engine.py +3 -1
- package/runtime/python/okstra_ctl/dispatch_core.py +19 -56
- package/runtime/python/okstra_ctl/dispatch_state.py +167 -3
- package/runtime/python/okstra_ctl/path_hints.py +6 -0
- package/runtime/python/okstra_ctl/paths.py +7 -44
- package/runtime/python/okstra_ctl/render.py +79 -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 +579 -32
- package/runtime/python/okstra_ctl/worker_liveness.py +84 -21
- package/runtime/python/okstra_ctl/worker_prompt_body.py +24 -4
- package/runtime/python/okstra_ctl/worker_prompt_contract.py +57 -0
- package/runtime/python/okstra_ctl/worker_prompt_policy.py +3 -0
- package/runtime/python/okstra_ctl/worker_state.py +65 -0
- package/runtime/python/okstra_ctl/workflow.py +22 -0
- package/runtime/python/okstra_token_usage/antigravity.py +3 -0
- package/runtime/python/okstra_token_usage/codex.py +54 -23
- package/runtime/python/okstra_token_usage/collect.py +141 -33
- package/runtime/python/okstra_token_usage/paths.py +27 -0
- package/runtime/python/okstra_vendor/__init__.py +15 -2
- package/runtime/schemas/convergence-groups-v1.0.schema.json +0 -1
- package/runtime/schemas/final-report-v1.0.schema.json +849 -3
- package/runtime/skills/okstra-run/SKILL.md +27 -5
- package/runtime/skills/okstra-setup/references/project-config.md +13 -4
- 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/lib/fixtures.sh +1 -1
- package/runtime/validators/validate-report-views.py +61 -7
- package/runtime/validators/validate-run.py +94 -1
- package/runtime/validators/validate_analysis_report.py +895 -0
- package/src/cli-registry.mjs +7 -10
- package/src/commands/execute/render-bundle.mjs +3 -0
- package/src/commands/execute/worker-state.mjs +29 -0
- package/src/commands/inspect/worker-liveness.mjs +5 -3
- package/src/commands/lifecycle/preflight.mjs +13 -3
- package/src/lib/runtime-readiness.mjs +90 -0
- package/runtime/python/okstra_ctl/phase_cleanup.py +0 -235
- package/src/commands/execute/phase-cleanup.mjs +0 -38
|
@@ -188,6 +188,7 @@ def _hydrate_active_run(ctx: Mapping[str, str]) -> dict[str, str]:
|
|
|
188
188
|
"teamStatePath": ctx.get("TEAM_STATE_RELATIVE_PATH", ""),
|
|
189
189
|
"promptSnapshotPath": ctx.get("RUN_PROMPT_SNAPSHOT_RELATIVE_PATH", ""),
|
|
190
190
|
"finalReportPath": ctx.get("FINAL_REPORT_RELATIVE_PATH", ""),
|
|
191
|
+
"convergenceStatePath": ctx.get("CONVERGENCE_STATE_RELATIVE_PATH", ""),
|
|
191
192
|
"finalStatusPath": ctx.get("FINAL_STATUS_RELATIVE_PATH", ""),
|
|
192
193
|
"validatorScriptPath": ctx.get("RUN_VALIDATOR_RELATIVE_PATH", ""),
|
|
193
194
|
"resumeCommandPath": ctx.get("CLAUDE_RESUME_COMMAND_RELATIVE_PATH", ""),
|
|
@@ -430,6 +431,7 @@ def _run_files(
|
|
|
430
431
|
"team_state": run_state / f"team-state{suffixes['state']}.json",
|
|
431
432
|
"active_run_context": run_state / f"active-run-context{suffixes['state']}.json",
|
|
432
433
|
"lead_events": run_state / f"lead-events-{task_type_segment}-{sequences['state']}.jsonl",
|
|
434
|
+
"convergence_state": run_state / f"convergence-{task_type_segment}-{sequences['state']}.json",
|
|
433
435
|
"claude_resume_command": run_sessions / f"claude-resume{suffixes['sessions']}.sh",
|
|
434
436
|
"claude_worker_result": worker_results / f"claude-worker{suffixes['worker_results']}.md",
|
|
435
437
|
"codex_worker_result": worker_results / f"codex-worker{suffixes['worker_results']}.md",
|
|
@@ -540,6 +542,7 @@ def _absolute_run_fields(paths: Mapping[str, Any]) -> dict[str, str]:
|
|
|
540
542
|
"RUN_CONTEXT_FILE": str(paths["run_context_file"]),
|
|
541
543
|
"RUN_PROMPT_SNAPSHOT_FILE": str(paths["run_prompt_snapshot"]),
|
|
542
544
|
"FINAL_REPORT_PATH": str(paths["final_report"]),
|
|
545
|
+
"CONVERGENCE_STATE_PATH": str(paths["convergence_state"]),
|
|
543
546
|
"FINAL_STATUS_PATH": str(paths["final_status"]),
|
|
544
547
|
"TEAM_STATE_PATH": str(paths["team_state"]),
|
|
545
548
|
"ACTIVE_RUN_CONTEXT_PATH": str(paths["active_run_context"]),
|
|
@@ -629,6 +632,9 @@ def _relative_file_fields(project_root: Path, paths: Mapping[str, Any]) -> dict[
|
|
|
629
632
|
"ANTIGRAVITY_WORKER_PROMPT_RELATIVE_PATH": _rel(project_root, paths["antigravity_worker_prompt"]),
|
|
630
633
|
"REPORT_WRITER_WORKER_PROMPT_RELATIVE_PATH": _rel(project_root, paths["report_writer_worker_prompt"]),
|
|
631
634
|
"FINAL_REPORT_RELATIVE_PATH": _rel(project_root, paths["final_report"]),
|
|
635
|
+
"CONVERGENCE_STATE_RELATIVE_PATH": _rel(
|
|
636
|
+
project_root, paths["convergence_state"]
|
|
637
|
+
),
|
|
632
638
|
"FINAL_STATUS_RELATIVE_PATH": _rel(project_root, paths["final_status"]),
|
|
633
639
|
"TEAM_STATE_RELATIVE_PATH": _rel(project_root, paths["team_state"]),
|
|
634
640
|
"ACTIVE_RUN_CONTEXT_RELATIVE_PATH": _rel(project_root, paths["active_run_context"]),
|
|
@@ -66,19 +66,6 @@ def _newest_report(reports_dir: Path) -> Optional[Path]:
|
|
|
66
66
|
return max(found, key=lambda p: (p.stat().st_mtime, p.name))
|
|
67
67
|
|
|
68
68
|
|
|
69
|
-
def _report_dirs(type_dir: Path, include_stages: bool) -> list[Path]:
|
|
70
|
-
"""한 task-type 의 report 디렉터리들. flat 은 항상, stage-<N> 은 opt-in.
|
|
71
|
-
|
|
72
|
-
stage-isolated task-type(implementation / final-verification)만 stage 하위를
|
|
73
|
-
갖는다. `include_stages` 가 꺼져 있으면 flat `reports/` 만 돌려준다 —
|
|
74
|
-
`latest_under` 의 원래 범위이자 resume-clarification 이 의존하는 계약이다.
|
|
75
|
-
"""
|
|
76
|
-
dirs = [type_dir / "reports"]
|
|
77
|
-
if include_stages and type_dir.name in _STAGED_TASK_TYPES:
|
|
78
|
-
dirs += sorted(type_dir.glob("stage-*/reports"))
|
|
79
|
-
return dirs
|
|
80
|
-
|
|
81
|
-
|
|
82
69
|
def _project_root_of(task_root: Path) -> Optional[Path]:
|
|
83
70
|
"""canonical `<project>/.okstra/tasks/<g>/<t>` 일 때만 project root.
|
|
84
71
|
|
|
@@ -264,42 +251,19 @@ class RunRef:
|
|
|
264
251
|
return None if best is None else cls.from_report_path(best)
|
|
265
252
|
|
|
266
253
|
@classmethod
|
|
267
|
-
def
|
|
254
|
+
def latest_under(
|
|
268
255
|
cls,
|
|
269
|
-
|
|
270
|
-
task_group: str,
|
|
271
|
-
task_id: str,
|
|
256
|
+
task_root: Path,
|
|
272
257
|
task_types: Optional[tuple[str, ...]] = None,
|
|
273
|
-
*,
|
|
274
|
-
include_stages: bool = False,
|
|
275
258
|
) -> Optional["RunRef"]:
|
|
276
|
-
"""여러 task-type 을 가로질러 최신 final-report 를
|
|
259
|
+
"""task_root 아래 여러 task-type 을 가로질러 최신 final-report 를 찾는다.
|
|
277
260
|
|
|
278
261
|
`task_types` 를 주지 않으면 runs/ 에 실제로 존재하는 모든 task-type 을
|
|
279
262
|
훑는다. 비교 규칙은 `latest` 와 같다(mtime, 동률이면 basename).
|
|
280
263
|
|
|
281
|
-
`include_stages` 를 켜면 stage-isolated task-type 의 `stage-<N>/reports/`
|
|
282
|
-
도 함께 훑는다 — phase-cleanup 이 staged run 을 자동발견할 때 쓴다.
|
|
283
|
-
"""
|
|
284
|
-
return cls.latest_under(
|
|
285
|
-
task_dir(project_root, task_group, task_id),
|
|
286
|
-
task_types,
|
|
287
|
-
include_stages=include_stages,
|
|
288
|
-
)
|
|
289
|
-
|
|
290
|
-
@classmethod
|
|
291
|
-
def latest_under(
|
|
292
|
-
cls,
|
|
293
|
-
task_root: Path,
|
|
294
|
-
task_types: Optional[tuple[str, ...]] = None,
|
|
295
|
-
*,
|
|
296
|
-
include_stages: bool = False,
|
|
297
|
-
) -> Optional["RunRef"]:
|
|
298
|
-
"""`latest_across` 의 task_root 진입점.
|
|
299
|
-
|
|
300
264
|
task-key 단축키/오타 진단을 이미 거쳐 task_root 를 손에 쥔 호출자
|
|
301
265
|
(bash resume-clarification)가 쓴다. 그 호출자는 언제나 flat 분석 phase 만
|
|
302
|
-
넘기므로
|
|
266
|
+
넘기므로 stage 하위는 훑지 않는다 — flat `reports/` 고정이 그 계약이다.
|
|
303
267
|
"""
|
|
304
268
|
runs_dir = runs_dir_of(task_root)
|
|
305
269
|
if task_types is None:
|
|
@@ -308,10 +272,9 @@ class RunRef:
|
|
|
308
272
|
task_types = tuple(sorted(p.name for p in runs_dir.iterdir() if p.is_dir()))
|
|
309
273
|
candidates: list[Path] = []
|
|
310
274
|
for task_type in task_types:
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
candidates.append(found)
|
|
275
|
+
found = _newest_report(runs_dir / task_type / "reports")
|
|
276
|
+
if found is not None:
|
|
277
|
+
candidates.append(found)
|
|
315
278
|
if not candidates:
|
|
316
279
|
return None
|
|
317
280
|
return cls.from_report_path(
|
|
@@ -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
|
|
@@ -346,6 +347,7 @@ def _active_run(ctx: dict) -> dict:
|
|
|
346
347
|
"teamStatePath": ctx.get("TEAM_STATE_RELATIVE_PATH", ""),
|
|
347
348
|
"promptSnapshotPath": ctx.get("RUN_PROMPT_SNAPSHOT_RELATIVE_PATH", ""),
|
|
348
349
|
"finalReportPath": ctx.get("FINAL_REPORT_RELATIVE_PATH", ""),
|
|
350
|
+
"convergenceStatePath": ctx.get("CONVERGENCE_STATE_RELATIVE_PATH", ""),
|
|
349
351
|
"finalStatusPath": ctx.get("FINAL_STATUS_RELATIVE_PATH", ""),
|
|
350
352
|
"validatorScriptPath": ctx.get("RUN_VALIDATOR_RELATIVE_PATH", ""),
|
|
351
353
|
"resumeCommandPath": ctx.get("CLAUDE_RESUME_COMMAND_RELATIVE_PATH", ""),
|
|
@@ -874,6 +876,45 @@ def _required_worker_roles(ctx: dict, reviewers: list[str]) -> list[dict]:
|
|
|
874
876
|
]
|
|
875
877
|
|
|
876
878
|
|
|
879
|
+
def _reporter_confirmation_status(brief_bytes: bytes) -> str:
|
|
880
|
+
try:
|
|
881
|
+
lines = brief_bytes.decode("utf-8").splitlines()
|
|
882
|
+
except UnicodeDecodeError:
|
|
883
|
+
return ""
|
|
884
|
+
if not lines or lines[0].strip() != "---":
|
|
885
|
+
return ""
|
|
886
|
+
for line in lines[1:]:
|
|
887
|
+
if line.strip() == "---":
|
|
888
|
+
break
|
|
889
|
+
key, separator, value = line.partition(":")
|
|
890
|
+
if separator and key.strip() == "reporter-confirmations":
|
|
891
|
+
return value.strip().strip("'\"").lower()
|
|
892
|
+
return ""
|
|
893
|
+
|
|
894
|
+
|
|
895
|
+
def _analysis_scope_confirmation_snapshot(ctx: dict) -> dict | None:
|
|
896
|
+
if ctx.get("TASK_TYPE") not in ANALYSIS_TASK_TYPES:
|
|
897
|
+
return None
|
|
898
|
+
brief_path = Path(ctx.get("BRIEF_FILE_PATH", ""))
|
|
899
|
+
try:
|
|
900
|
+
brief_bytes = brief_path.read_bytes() if brief_path.is_file() else None
|
|
901
|
+
except OSError:
|
|
902
|
+
brief_bytes = None
|
|
903
|
+
return {
|
|
904
|
+
"taskBriefPath": ctx.get("BRIEF_RELATIVE_PATH", ""),
|
|
905
|
+
"status": (
|
|
906
|
+
_reporter_confirmation_status(brief_bytes)
|
|
907
|
+
if brief_bytes is not None
|
|
908
|
+
else ""
|
|
909
|
+
),
|
|
910
|
+
"briefSha256": (
|
|
911
|
+
hashlib.sha256(brief_bytes).hexdigest()
|
|
912
|
+
if brief_bytes is not None
|
|
913
|
+
else ""
|
|
914
|
+
),
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
|
|
877
918
|
def _derive_phase_states(existing_workflow: dict, ctx: dict) -> tuple[dict, str, str]:
|
|
878
919
|
"""phaseStates dict + (current_phase, current_phase_state) 를 도출한다.
|
|
879
920
|
|
|
@@ -1204,9 +1245,8 @@ def _build_convergence_block(ctx: dict) -> dict:
|
|
|
1204
1245
|
- `enabled` default True
|
|
1205
1246
|
- `maxRounds` default 1 for `requirements-discovery`, 2 otherwise
|
|
1206
1247
|
- `verificationMode` default "lightweight"
|
|
1207
|
-
- `adversarial` default True for
|
|
1208
|
-
|
|
1209
|
-
False otherwise
|
|
1248
|
+
- `adversarial` default True for discovery, planning, and analysis task types
|
|
1249
|
+
(forces `verificationMode` to "full-reanalysis"), False otherwise
|
|
1210
1250
|
- `planBodyVerification` is implementation-planning specific; the key is
|
|
1211
1251
|
always emitted (dead-letter on other phases) so the schema stays stable.
|
|
1212
1252
|
Its `selfFixMaxRounds` default 3 bounds the report-writer self-fix loop
|
|
@@ -1222,7 +1262,14 @@ def _build_convergence_block(ctx: dict) -> dict:
|
|
|
1222
1262
|
"""
|
|
1223
1263
|
task_type = ctx.get("TASK_TYPE", "")
|
|
1224
1264
|
default_max_rounds = 1 if task_type == "requirements-discovery" else 2
|
|
1225
|
-
adversarial_phases = {
|
|
1265
|
+
adversarial_phases = {
|
|
1266
|
+
"requirements-discovery",
|
|
1267
|
+
"error-analysis",
|
|
1268
|
+
"implementation-planning",
|
|
1269
|
+
"project-analysis",
|
|
1270
|
+
"feature-analysis",
|
|
1271
|
+
"change-impact-analysis",
|
|
1272
|
+
}
|
|
1226
1273
|
is_adversarial = task_type in adversarial_phases
|
|
1227
1274
|
raw_plan_verify = (ctx.get("OKSTRA_PLAN_VERIFICATION", "") or "").strip().lower()
|
|
1228
1275
|
plan_verify_enabled = raw_plan_verify != "false"
|
|
@@ -1257,6 +1304,20 @@ def _build_convergence_block(ctx: dict) -> dict:
|
|
|
1257
1304
|
|
|
1258
1305
|
|
|
1259
1306
|
def render_run_manifest(run_manifest_path: str, ctx: dict) -> None:
|
|
1307
|
+
run_manifest_file = Path(run_manifest_path)
|
|
1308
|
+
existing_run_manifest = {}
|
|
1309
|
+
if run_manifest_file.is_file():
|
|
1310
|
+
try:
|
|
1311
|
+
loaded_run_manifest = json.loads(
|
|
1312
|
+
run_manifest_file.read_text(encoding="utf-8")
|
|
1313
|
+
)
|
|
1314
|
+
existing_run_manifest = (
|
|
1315
|
+
loaded_run_manifest
|
|
1316
|
+
if isinstance(loaded_run_manifest, dict)
|
|
1317
|
+
else {}
|
|
1318
|
+
)
|
|
1319
|
+
except (OSError, json.JSONDecodeError):
|
|
1320
|
+
existing_run_manifest = {}
|
|
1260
1321
|
task_manifest_path = Path(ctx.get("TASK_MANIFEST_PATH", ""))
|
|
1261
1322
|
task_manifest = {}
|
|
1262
1323
|
if task_manifest_path.exists():
|
|
@@ -1329,7 +1390,16 @@ def render_run_manifest(run_manifest_path: str, ctx: dict) -> None:
|
|
|
1329
1390
|
"expectedStatusPath": ctx.get("FINAL_STATUS_RELATIVE_PATH", ""),
|
|
1330
1391
|
"teamStatePath": ctx.get("TEAM_STATE_RELATIVE_PATH", ""),
|
|
1331
1392
|
"activeRunContextPath": ctx.get("ACTIVE_RUN_CONTEXT_RELATIVE_PATH", ""),
|
|
1393
|
+
"convergenceStatePath": ctx.get("CONVERGENCE_STATE_RELATIVE_PATH", ""),
|
|
1332
1394
|
"analysisPacketPath": ctx.get("ANALYSIS_PACKET_RELATIVE_PATH", ""),
|
|
1395
|
+
"analysisEvidencePath": (
|
|
1396
|
+
ctx.get("INSTRUCTION_SET_RELATIVE_PATH", "") + "/analysis-evidence.md"
|
|
1397
|
+
if ctx.get("TASK_TYPE") in {"project-analysis", "feature-analysis", "change-impact-analysis"}
|
|
1398
|
+
else ""
|
|
1399
|
+
),
|
|
1400
|
+
"analysisSourceCommit": ctx.get("ANALYSIS_SOURCE_COMMIT", ""),
|
|
1401
|
+
"analysisTarget": json.loads(ctx.get("ANALYSIS_TARGET_JSON", "{}")),
|
|
1402
|
+
"evidenceInputs": json.loads(ctx.get("EVIDENCE_INPUTS_JSON", "[]")),
|
|
1333
1403
|
"verificationTargetPath": ctx.get("VERIFICATION_TARGET_RELATIVE_PATH", ""),
|
|
1334
1404
|
"verificationTargetDigest": ctx.get("VERIFICATION_TARGET_DIGEST", ""),
|
|
1335
1405
|
"workerResultsDirectoryPath": ctx.get("WORKER_RESULTS_RELATIVE_PATH", ""),
|
|
@@ -1403,6 +1473,11 @@ def render_run_manifest(run_manifest_path: str, ctx: dict) -> None:
|
|
|
1403
1473
|
"renderOnly": ctx.get("RENDER_ONLY", ""),
|
|
1404
1474
|
"createdAt": ctx.get("RUN_TIMESTAMP_ISO", ""),
|
|
1405
1475
|
}
|
|
1476
|
+
scope_confirmation = existing_run_manifest.get("analysisScopeConfirmation")
|
|
1477
|
+
if "analysisScopeConfirmation" not in existing_run_manifest:
|
|
1478
|
+
scope_confirmation = _analysis_scope_confirmation_snapshot(ctx)
|
|
1479
|
+
if scope_confirmation is not None:
|
|
1480
|
+
payload["analysisScopeConfirmation"] = scope_confirmation
|
|
1406
1481
|
if ctx.get("FIX_CYCLE_ID"):
|
|
1407
1482
|
payload["fixCycleId"] = ctx["FIX_CYCLE_ID"]
|
|
1408
1483
|
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
|
|
|
@@ -108,6 +108,11 @@ class RunMeta:
|
|
|
108
108
|
source_report: str # relative path of the .md the HTML is derived from
|
|
109
109
|
|
|
110
110
|
|
|
111
|
+
@dataclass(frozen=True)
|
|
112
|
+
class AnalysisReviewContext:
|
|
113
|
+
selector_ids: tuple[str, ...]
|
|
114
|
+
|
|
115
|
+
|
|
111
116
|
# task-type itself can contain hyphens (``implementation-planning``,
|
|
112
117
|
# ``final-verification``, ``release-handoff``), so the filename segment
|
|
113
118
|
# between ``final-report-`` and ``-<seq>.md`` is matched greedily; the
|
|
@@ -159,6 +164,7 @@ class ReportViewModel:
|
|
|
159
164
|
sidecar_dir: str
|
|
160
165
|
approval_ctx: PlanApprovalContext | None = None
|
|
161
166
|
reader_ctx: ReaderDashboardContext | None = None
|
|
167
|
+
analysis_review_ctx: AnalysisReviewContext | None = None
|
|
162
168
|
|
|
163
169
|
|
|
164
170
|
@dataclass(frozen=True)
|
|
@@ -179,6 +185,7 @@ def build_report_view_model(
|
|
|
179
185
|
run_meta: RunMeta,
|
|
180
186
|
approval_ctx: PlanApprovalContext | None = None,
|
|
181
187
|
reader_ctx: ReaderDashboardContext | None = None,
|
|
188
|
+
analysis_review_ctx: AnalysisReviewContext | None = None,
|
|
182
189
|
) -> ReportViewModel:
|
|
183
190
|
digest = source_digest(src_md)
|
|
184
191
|
body_md = _strip_leading_frontmatter(src_md)
|
|
@@ -197,6 +204,7 @@ def build_report_view_model(
|
|
|
197
204
|
sidecar_dir=f"runs/{run_meta.task_type}/user-responses/",
|
|
198
205
|
approval_ctx=approval_ctx,
|
|
199
206
|
reader_ctx=reader_ctx,
|
|
207
|
+
analysis_review_ctx=analysis_review_ctx,
|
|
200
208
|
)
|
|
201
209
|
|
|
202
210
|
|
|
@@ -258,6 +266,7 @@ def render_report_view_model(
|
|
|
258
266
|
f"</header>\n"
|
|
259
267
|
f"<main>{_reader_dashboard(model.reader_ctx)}{model.body_html}</main>\n"
|
|
260
268
|
f"{_plan_approval_section(model.approval_ctx, run_meta) if model.approval_ctx else ''}"
|
|
269
|
+
f"{_analysis_review_section(model.analysis_review_ctx) if model.analysis_review_ctx else ''}"
|
|
261
270
|
f"<footer class=\"report-footer\">\n"
|
|
262
271
|
f" <button type=\"button\" data-action=\"export-user-response\">Export user response</button>\n"
|
|
263
272
|
f" <p class=\"user-response-hint\">Export 클릭 시 <code>{sidecar_name}</code> 가 다운로드됩니다 — "
|
|
@@ -288,6 +297,7 @@ def render_html(
|
|
|
288
297
|
js: str,
|
|
289
298
|
approval_ctx: PlanApprovalContext | None = None,
|
|
290
299
|
reader_ctx: ReaderDashboardContext | None = None,
|
|
300
|
+
analysis_review_ctx: AnalysisReviewContext | None = None,
|
|
291
301
|
) -> str:
|
|
292
302
|
"""Return a single self-contained HTML document for ``src_md``.
|
|
293
303
|
|
|
@@ -300,6 +310,7 @@ def render_html(
|
|
|
300
310
|
run_meta=run_meta,
|
|
301
311
|
approval_ctx=approval_ctx,
|
|
302
312
|
reader_ctx=reader_ctx,
|
|
313
|
+
analysis_review_ctx=analysis_review_ctx,
|
|
303
314
|
)
|
|
304
315
|
return render_report_view_model(model, css=css, js=js)
|
|
305
316
|
|
|
@@ -1021,12 +1032,56 @@ class UserResponseApproval:
|
|
|
1021
1032
|
implementation_option: str = ""
|
|
1022
1033
|
|
|
1023
1034
|
|
|
1035
|
+
@dataclass(frozen=True)
|
|
1036
|
+
class UserResponseAnalysisReview:
|
|
1037
|
+
status: str
|
|
1038
|
+
affected_ids: tuple[str, ...] = ()
|
|
1039
|
+
reason: str = ""
|
|
1040
|
+
additional_evidence: str = ""
|
|
1041
|
+
requested_scope_change: str = ""
|
|
1042
|
+
|
|
1043
|
+
|
|
1044
|
+
_ANALYSIS_REVIEW_STATUSES = frozenset({
|
|
1045
|
+
"accepted",
|
|
1046
|
+
"revision-requested",
|
|
1047
|
+
"rejected",
|
|
1048
|
+
})
|
|
1049
|
+
|
|
1050
|
+
|
|
1051
|
+
def _quoted_sidecar_field(label: str, value: str) -> str:
|
|
1052
|
+
cleaned = value.strip()
|
|
1053
|
+
if not cleaned:
|
|
1054
|
+
return f"- {label}:\n"
|
|
1055
|
+
quoted = "".join(f" > {line}\n" for line in cleaned.split("\n"))
|
|
1056
|
+
return f"- {label}:\n{quoted}"
|
|
1057
|
+
|
|
1058
|
+
|
|
1059
|
+
def _serialize_analysis_review(review: UserResponseAnalysisReview) -> str:
|
|
1060
|
+
if review.status not in _ANALYSIS_REVIEW_STATUSES:
|
|
1061
|
+
raise ValueError(f"invalid ANALYSIS REVIEW status: {review.status}")
|
|
1062
|
+
if review.status in {"revision-requested", "rejected"} and (
|
|
1063
|
+
not review.affected_ids or not review.reason.strip()
|
|
1064
|
+
):
|
|
1065
|
+
raise ValueError(
|
|
1066
|
+
f"ANALYSIS REVIEW {review.status} requires Affected-IDs and Reason"
|
|
1067
|
+
)
|
|
1068
|
+
return (
|
|
1069
|
+
"\n## ANALYSIS REVIEW\n"
|
|
1070
|
+
f"- Status: {review.status}\n"
|
|
1071
|
+
f"- Affected-IDs: {', '.join(review.affected_ids)}\n"
|
|
1072
|
+
f"{_quoted_sidecar_field('Reason', review.reason)}"
|
|
1073
|
+
f"{_quoted_sidecar_field('Additional-Evidence', review.additional_evidence)}"
|
|
1074
|
+
f"{_quoted_sidecar_field('Requested-Scope-Change', review.requested_scope_change)}"
|
|
1075
|
+
)
|
|
1076
|
+
|
|
1077
|
+
|
|
1024
1078
|
def serialize_user_response(
|
|
1025
1079
|
*,
|
|
1026
1080
|
run_meta: RunMeta,
|
|
1027
1081
|
entries: list[UserResponseEntry],
|
|
1028
1082
|
created_at: str,
|
|
1029
1083
|
approval: UserResponseApproval | None = None,
|
|
1084
|
+
analysis_review: UserResponseAnalysisReview | None = None,
|
|
1030
1085
|
) -> str:
|
|
1031
1086
|
"""Return the canonical markdown text the HTML 'Export user
|
|
1032
1087
|
response' button must produce. Used by validators to confirm that
|
|
@@ -1046,6 +1101,7 @@ def serialize_user_response(
|
|
|
1046
1101
|
"# User Response\n"
|
|
1047
1102
|
)
|
|
1048
1103
|
has_approval = approval is not None and approval.approved
|
|
1104
|
+
has_analysis_review = analysis_review is not None
|
|
1049
1105
|
body_chunks: list[str] = []
|
|
1050
1106
|
for e in entries:
|
|
1051
1107
|
chunk = f"\n## {e.response_id}\n- Kind: {e.kind}\n"
|
|
@@ -1056,13 +1112,15 @@ def serialize_user_response(
|
|
|
1056
1112
|
if e.rationale:
|
|
1057
1113
|
chunk += f"- Rationale: {e.rationale.strip()}\n"
|
|
1058
1114
|
body_chunks.append(chunk)
|
|
1059
|
-
if not entries and not has_approval:
|
|
1115
|
+
if not entries and not has_approval and not has_analysis_review:
|
|
1060
1116
|
body_chunks.append("\n_(No user responses recorded.)_\n")
|
|
1061
1117
|
if has_approval:
|
|
1062
1118
|
chunk = "\n## APPROVAL\n- Approved: true\n"
|
|
1063
1119
|
if approval.implementation_option:
|
|
1064
1120
|
chunk += f"- Implementation-Option: {approval.implementation_option.strip()}\n"
|
|
1065
1121
|
body_chunks.append(chunk)
|
|
1122
|
+
if analysis_review is not None:
|
|
1123
|
+
body_chunks.append(_serialize_analysis_review(analysis_review))
|
|
1066
1124
|
return head + "".join(body_chunks)
|
|
1067
1125
|
|
|
1068
1126
|
|
|
@@ -1107,6 +1165,39 @@ def _load_report_data(src_md_path: Path) -> dict | None:
|
|
|
1107
1165
|
return data if isinstance(data, dict) else None
|
|
1108
1166
|
|
|
1109
1167
|
|
|
1168
|
+
_ANALYSIS_DATA_KEYS = (
|
|
1169
|
+
"analysisCommon",
|
|
1170
|
+
"projectAnalysis",
|
|
1171
|
+
"featureAnalysis",
|
|
1172
|
+
"changeImpactAnalysis",
|
|
1173
|
+
)
|
|
1174
|
+
_STRUCTURED_ANALYSIS_ID_RE = re.compile(r"^[A-Z]{2}-\d{3}$")
|
|
1175
|
+
|
|
1176
|
+
|
|
1177
|
+
def _collect_structured_analysis_ids(value: object, found: set[str]) -> None:
|
|
1178
|
+
if isinstance(value, dict):
|
|
1179
|
+
candidate = value.get("id")
|
|
1180
|
+
if isinstance(candidate, str) and _STRUCTURED_ANALYSIS_ID_RE.fullmatch(
|
|
1181
|
+
candidate
|
|
1182
|
+
):
|
|
1183
|
+
found.add(candidate)
|
|
1184
|
+
for child in value.values():
|
|
1185
|
+
_collect_structured_analysis_ids(child, found)
|
|
1186
|
+
elif isinstance(value, list):
|
|
1187
|
+
for child in value:
|
|
1188
|
+
_collect_structured_analysis_ids(child, found)
|
|
1189
|
+
|
|
1190
|
+
|
|
1191
|
+
def analysis_review_context(src_md_path: Path) -> AnalysisReviewContext | None:
|
|
1192
|
+
data = _load_report_data(src_md_path)
|
|
1193
|
+
if data is None or not isinstance(data.get("analysisCommon"), dict):
|
|
1194
|
+
return None
|
|
1195
|
+
found: set[str] = set()
|
|
1196
|
+
for key in _ANALYSIS_DATA_KEYS:
|
|
1197
|
+
_collect_structured_analysis_ids(data.get(key), found)
|
|
1198
|
+
return AnalysisReviewContext(selector_ids=tuple(sorted(found)))
|
|
1199
|
+
|
|
1200
|
+
|
|
1110
1201
|
def plan_approval_context(
|
|
1111
1202
|
src_md_path: Path, src_text: str
|
|
1112
1203
|
) -> PlanApprovalContext | None:
|
|
@@ -1298,6 +1389,35 @@ def _plan_approval_section(ctx: PlanApprovalContext, run_meta: RunMeta) -> str:
|
|
|
1298
1389
|
)
|
|
1299
1390
|
|
|
1300
1391
|
|
|
1392
|
+
def _analysis_review_section(ctx: AnalysisReviewContext) -> str:
|
|
1393
|
+
options = "".join(
|
|
1394
|
+
f'<option value="{html.escape(analysis_id)}">'
|
|
1395
|
+
f"{html.escape(analysis_id)}</option>"
|
|
1396
|
+
for analysis_id in ctx.selector_ids
|
|
1397
|
+
)
|
|
1398
|
+
return (
|
|
1399
|
+
'<section id="analysis-review">\n'
|
|
1400
|
+
" <h2>Analysis Review</h2>\n"
|
|
1401
|
+
' <fieldset><legend>Decision</legend>\n'
|
|
1402
|
+
' <label><input type="radio" name="analysis-review-status" '
|
|
1403
|
+
'value="accepted">Accept</label>\n'
|
|
1404
|
+
' <label><input type="radio" name="analysis-review-status" '
|
|
1405
|
+
'value="revision-requested">Request revision</label>\n'
|
|
1406
|
+
' <label><input type="radio" name="analysis-review-status" '
|
|
1407
|
+
'value="rejected">Reject</label>\n'
|
|
1408
|
+
" </fieldset>\n"
|
|
1409
|
+
' <label>Affected IDs <select id="analysis-review-affected-ids" '
|
|
1410
|
+
f'multiple>{options}</select></label>\n'
|
|
1411
|
+
' <label>Reason <textarea id="analysis-review-reason" rows="3">'
|
|
1412
|
+
"</textarea></label>\n"
|
|
1413
|
+
' <label>Additional evidence <textarea id="analysis-review-evidence" '
|
|
1414
|
+
'rows="3"></textarea></label>\n'
|
|
1415
|
+
' <label>Requested scope change <textarea '
|
|
1416
|
+
'id="analysis-review-scope-change" rows="3"></textarea></label>\n'
|
|
1417
|
+
"</section>\n"
|
|
1418
|
+
)
|
|
1419
|
+
|
|
1420
|
+
|
|
1301
1421
|
def render_html_view(
|
|
1302
1422
|
src_md_path: Path,
|
|
1303
1423
|
*,
|
|
@@ -1327,9 +1447,15 @@ def render_html_view(
|
|
|
1327
1447
|
"the HTML view."
|
|
1328
1448
|
)
|
|
1329
1449
|
approval_ctx = plan_approval_context(src_md_path, src_text)
|
|
1450
|
+
analysis_review_ctx = analysis_review_context(src_md_path)
|
|
1330
1451
|
reader_ctx = reader_dashboard_context(src_md_path, src_text, approval_ctx)
|
|
1331
1452
|
has_clarifications = report_has_clarification_items(src_text)
|
|
1332
|
-
if
|
|
1453
|
+
if (
|
|
1454
|
+
not has_clarifications
|
|
1455
|
+
and approval_ctx is None
|
|
1456
|
+
and reader_ctx is None
|
|
1457
|
+
and analysis_review_ctx is None
|
|
1458
|
+
):
|
|
1333
1459
|
if html_path.is_file():
|
|
1334
1460
|
html_path.unlink()
|
|
1335
1461
|
return None
|
|
@@ -1340,9 +1466,14 @@ def render_html_view(
|
|
|
1340
1466
|
js=js,
|
|
1341
1467
|
approval_ctx=approval_ctx,
|
|
1342
1468
|
reader_ctx=reader_ctx,
|
|
1469
|
+
analysis_review_ctx=analysis_review_ctx,
|
|
1343
1470
|
)
|
|
1344
1471
|
html_path.write_text(html_text, encoding="utf-8")
|
|
1345
|
-
if
|
|
1472
|
+
if (
|
|
1473
|
+
has_clarifications
|
|
1474
|
+
or approval_ctx is not None
|
|
1475
|
+
or analysis_review_ctx is not None
|
|
1476
|
+
):
|
|
1346
1477
|
# 사용자 확인(폼/승인)이 필요한 보고서 — Export 파일의 저장 위치를 미리
|
|
1347
1478
|
# 만들어 사용자가 디렉토리를 만들 필요가 없게 한다 (reports/ 의 sibling).
|
|
1348
1479
|
user_responses_dir_for_report(src_md_path).mkdir(parents=True, exist_ok=True)
|