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
|
@@ -31,6 +31,15 @@ from okstra_project import project_json_path, upsert_project_json
|
|
|
31
31
|
from okstra_project.state import slugify
|
|
32
32
|
from . import fix_cycles
|
|
33
33
|
from .analysis_packet import build_analysis_packet
|
|
34
|
+
from .analysis_inputs import (
|
|
35
|
+
ANALYSIS_TASK_TYPES,
|
|
36
|
+
AnalysisInputError,
|
|
37
|
+
load_candidate_map,
|
|
38
|
+
parse_evidence_paths,
|
|
39
|
+
resolve_analysis_head,
|
|
40
|
+
resolve_analysis_target,
|
|
41
|
+
resolve_evidence_inputs,
|
|
42
|
+
)
|
|
34
43
|
from .stage_fix_carry import derive_stage_fix_carry
|
|
35
44
|
from .clarification_items import (
|
|
36
45
|
attached_user_responses_section,
|
|
@@ -71,6 +80,7 @@ from okstra_project.dirs import okstra_home
|
|
|
71
80
|
|
|
72
81
|
from .run_context import (
|
|
73
82
|
compute_and_write_run_context,
|
|
83
|
+
refresh_run_context_snapshot,
|
|
74
84
|
write_run_inputs,
|
|
75
85
|
)
|
|
76
86
|
from .seeding import (
|
|
@@ -311,6 +321,8 @@ class PrepareInputs:
|
|
|
311
321
|
task_id: str
|
|
312
322
|
task_type: str
|
|
313
323
|
brief_path: Path # absolute, already resolved
|
|
324
|
+
analysis_target: str = ""
|
|
325
|
+
evidence_inputs_raw: str = ""
|
|
314
326
|
directive: str = ""
|
|
315
327
|
workers_override: str = ""
|
|
316
328
|
lead_model: str = ""
|
|
@@ -758,6 +770,8 @@ def _canonical_argv(inp: PrepareInputs, ctx: dict) -> list[str]:
|
|
|
758
770
|
("--task-group", inp.task_group),
|
|
759
771
|
("--task-id", inp.task_id),
|
|
760
772
|
("--task-brief", str(inp.brief_path)),
|
|
773
|
+
("--analysis-target", inp.analysis_target),
|
|
774
|
+
("--evidence-inputs", inp.evidence_inputs_raw),
|
|
761
775
|
("--directive", inp.directive),
|
|
762
776
|
("--approved-plan", inp.approved_plan_path),
|
|
763
777
|
("--implementation-option", inp.implementation_option),
|
|
@@ -946,6 +960,7 @@ def _validate_prepare_inputs(project_root: Path, inp: PrepareInputs) -> list:
|
|
|
946
960
|
"""Validate pure prepare inputs and return a final-verification stage map."""
|
|
947
961
|
if not project_root.is_dir():
|
|
948
962
|
raise PrepareError(f"project root not found: {project_root}")
|
|
963
|
+
_validate_analysis_prepare_inputs(project_root, inp)
|
|
949
964
|
if inp.stages and inp.task_type != "release-handoff":
|
|
950
965
|
raise PrepareError(
|
|
951
966
|
"--stages is only meaningful with --task-type release-handoff; "
|
|
@@ -1028,6 +1043,41 @@ def _prepare_implementation_approved_plan(inp: PrepareInputs) -> list:
|
|
|
1028
1043
|
return _parse_stage_map_into_ctx(inp.approved_plan_path)
|
|
1029
1044
|
|
|
1030
1045
|
|
|
1046
|
+
def _validate_analysis_prepare_inputs(project_root: Path, inp: PrepareInputs) -> None:
|
|
1047
|
+
"""Reject invalid analysis inputs before task worktree or run sequence creation."""
|
|
1048
|
+
is_analysis = inp.task_type in ANALYSIS_TASK_TYPES
|
|
1049
|
+
if not is_analysis:
|
|
1050
|
+
if inp.analysis_target:
|
|
1051
|
+
raise PrepareError(
|
|
1052
|
+
f"--analysis-target is only accepted for analysis task types; got {inp.task_type}"
|
|
1053
|
+
)
|
|
1054
|
+
if inp.evidence_inputs_raw:
|
|
1055
|
+
raise PrepareError(
|
|
1056
|
+
f"--evidence-inputs is only accepted for analysis task types; got {inp.task_type}"
|
|
1057
|
+
)
|
|
1058
|
+
return
|
|
1059
|
+
if inp.task_type == "project-analysis":
|
|
1060
|
+
if inp.analysis_target:
|
|
1061
|
+
raise PrepareError("project-analysis does not accept an analysis target")
|
|
1062
|
+
if inp.evidence_inputs_raw:
|
|
1063
|
+
raise PrepareError("project-analysis does not accept evidence inputs")
|
|
1064
|
+
return
|
|
1065
|
+
if inp.task_type == "feature-analysis" and not inp.analysis_target.strip():
|
|
1066
|
+
raise PrepareError("feature-analysis requires --analysis-target")
|
|
1067
|
+
if inp.task_type != "feature-analysis" and inp.analysis_target:
|
|
1068
|
+
raise PrepareError(f"{inp.task_type} does not accept an analysis target")
|
|
1069
|
+
paths = parse_evidence_paths(inp.evidence_inputs_raw, project_root)
|
|
1070
|
+
try:
|
|
1071
|
+
candidates = load_candidate_map(project_root, paths)
|
|
1072
|
+
evidence = resolve_evidence_inputs(
|
|
1073
|
+
project_root, inp.task_type, paths, "0" * 40,
|
|
1074
|
+
)
|
|
1075
|
+
if inp.task_type == "feature-analysis":
|
|
1076
|
+
resolve_analysis_target(inp.analysis_target, evidence, candidates)
|
|
1077
|
+
except AnalysisInputError as exc:
|
|
1078
|
+
raise PrepareError(str(exc)) from exc
|
|
1079
|
+
|
|
1080
|
+
|
|
1031
1081
|
def _collect_handoff_source_report_rows(
|
|
1032
1082
|
rows: list, nums: list,
|
|
1033
1083
|
) -> list:
|
|
@@ -1583,6 +1633,7 @@ def _write_instruction_set_sources(
|
|
|
1583
1633
|
reference-expectations 를 기록하고 디렉터리 경로를 돌려준다."""
|
|
1584
1634
|
instruction_set = Path(ctx["INSTRUCTION_SET_PATH"])
|
|
1585
1635
|
instruction_set.mkdir(parents=True, exist_ok=True)
|
|
1636
|
+
_write_analysis_evidence_artifact(ctx, instruction_set)
|
|
1586
1637
|
_write_verification_target_artifact(inp, ctx, instruction_set)
|
|
1587
1638
|
profile_rendered = profile_content
|
|
1588
1639
|
if inp.task_type == "implementation":
|
|
@@ -1658,10 +1709,43 @@ def _write_instruction_set_sources(
|
|
|
1658
1709
|
fix_history_text=fix_cycles.packet_summary(
|
|
1659
1710
|
fix_cycles.read_rows(Path(ctx["TASK_MANIFEST_PATH"]).parent)),
|
|
1660
1711
|
)
|
|
1712
|
+
if inp.task_type in ANALYSIS_TASK_TYPES:
|
|
1713
|
+
packet += (
|
|
1714
|
+
"\n## Analysis Evidence\n\n"
|
|
1715
|
+
f"- Analysis evidence: `{ctx['INSTRUCTION_SET_RELATIVE_PATH']}/analysis-evidence.md`\n"
|
|
1716
|
+
)
|
|
1661
1717
|
(instruction_set / "analysis-packet.md").write_text(packet, encoding="utf-8")
|
|
1662
1718
|
return instruction_set
|
|
1663
1719
|
|
|
1664
1720
|
|
|
1721
|
+
def _write_analysis_evidence_artifact(ctx: dict, instruction_set: Path) -> None:
|
|
1722
|
+
"""Write selected evidence metadata without copying the original reports."""
|
|
1723
|
+
if ctx.get("TASK_TYPE") not in ANALYSIS_TASK_TYPES:
|
|
1724
|
+
return
|
|
1725
|
+
evidence = json.loads(ctx.get("EVIDENCE_INPUTS_JSON", "[]"))
|
|
1726
|
+
lines = [
|
|
1727
|
+
"# Analysis Evidence",
|
|
1728
|
+
"",
|
|
1729
|
+
"Revalidate every cited finding against the current code before relying on it.",
|
|
1730
|
+
]
|
|
1731
|
+
for item in evidence:
|
|
1732
|
+
lines.extend([
|
|
1733
|
+
"",
|
|
1734
|
+
f"## {item['taskType']} / run {item['runSeq']}",
|
|
1735
|
+
"",
|
|
1736
|
+
f"- Metadata: task key `{item['taskKey']}`, source commit `{item['sourceCommit']}`, relation `{item['relation']}`",
|
|
1737
|
+
f"- Original report: `{item['reportPath']}`",
|
|
1738
|
+
f"- Review status: `{item['reviewStatus']}`",
|
|
1739
|
+
f"- Freshness: `{item['freshness']}`",
|
|
1740
|
+
"- Current-code obligation: revalidate cited findings against the current code.",
|
|
1741
|
+
])
|
|
1742
|
+
if not evidence:
|
|
1743
|
+
lines.extend(["", "- No evidence reports were selected."])
|
|
1744
|
+
(instruction_set / "analysis-evidence.md").write_text(
|
|
1745
|
+
"\n".join(lines) + "\n", encoding="utf-8",
|
|
1746
|
+
)
|
|
1747
|
+
|
|
1748
|
+
|
|
1665
1749
|
def _render_lead_prompt_and_snapshot(
|
|
1666
1750
|
inp: PrepareInputs,
|
|
1667
1751
|
ctx: dict,
|
|
@@ -1744,6 +1828,10 @@ def _persist_run_inputs(
|
|
|
1744
1828
|
"relatedTasks": inp.related_tasks_raw,
|
|
1745
1829
|
"approvedPlanPath": approved_plan_path,
|
|
1746
1830
|
"clarificationResponsePath": inp.clarification_response_path,
|
|
1831
|
+
"analysisTarget": json.loads(ctx.get("ANALYSIS_TARGET_JSON", "{}")).get(
|
|
1832
|
+
"requestedValue", ""
|
|
1833
|
+
),
|
|
1834
|
+
"evidenceInputs": json.loads(ctx.get("EVIDENCE_INPUTS_JSON", "[]")),
|
|
1747
1835
|
"renderOnly": inp.render_only,
|
|
1748
1836
|
},
|
|
1749
1837
|
)
|
|
@@ -2169,6 +2257,26 @@ def prepare_task_bundle(inp: PrepareInputs) -> PrepareOutputs:
|
|
|
2169
2257
|
stage_run_claim = provisioned.stage_run_claim
|
|
2170
2258
|
stage_arg = provisioned.stage_arg
|
|
2171
2259
|
|
|
2260
|
+
analysis_source_commit = ""
|
|
2261
|
+
resolved_evidence = ()
|
|
2262
|
+
resolved_target: dict[str, object] = {}
|
|
2263
|
+
if inp.task_type in ANALYSIS_TASK_TYPES:
|
|
2264
|
+
analysis_root = Path(worktree.path or project_root)
|
|
2265
|
+
evidence_paths = parse_evidence_paths(inp.evidence_inputs_raw, project_root)
|
|
2266
|
+
try:
|
|
2267
|
+
analysis_source_commit = resolve_analysis_head(analysis_root)
|
|
2268
|
+
resolved_evidence = resolve_evidence_inputs(
|
|
2269
|
+
project_root, inp.task_type, evidence_paths, analysis_source_commit,
|
|
2270
|
+
)
|
|
2271
|
+
if inp.task_type == "feature-analysis":
|
|
2272
|
+
resolved_target = resolve_analysis_target(
|
|
2273
|
+
inp.analysis_target,
|
|
2274
|
+
resolved_evidence,
|
|
2275
|
+
load_candidate_map(project_root, evidence_paths),
|
|
2276
|
+
)
|
|
2277
|
+
except AnalysisInputError as exc:
|
|
2278
|
+
raise PrepareError(str(exc)) from exc
|
|
2279
|
+
|
|
2172
2280
|
ctx = compute_and_write_run_context(
|
|
2173
2281
|
workspace_root=workspace_root, project_root=project_root,
|
|
2174
2282
|
project_id=inp.project_id, task_group=inp.task_group, task_id=inp.task_id,
|
|
@@ -2189,6 +2297,11 @@ def prepare_task_bundle(inp: PrepareInputs) -> PrepareOutputs:
|
|
|
2189
2297
|
"OKSTRA_PLAN_VERIFICATION": (
|
|
2190
2298
|
"false" if not inp.plan_verification_enabled else ""
|
|
2191
2299
|
),
|
|
2300
|
+
"ANALYSIS_SOURCE_COMMIT": analysis_source_commit,
|
|
2301
|
+
"ANALYSIS_TARGET_JSON": json.dumps(resolved_target, ensure_ascii=False),
|
|
2302
|
+
"EVIDENCE_INPUTS_JSON": json.dumps(
|
|
2303
|
+
[item.to_dict() for item in resolved_evidence], ensure_ascii=False,
|
|
2304
|
+
),
|
|
2192
2305
|
})
|
|
2193
2306
|
|
|
2194
2307
|
# implementation: override the task-worktree fields with the claimed
|
|
@@ -2254,6 +2367,7 @@ def prepare_task_bundle(inp: PrepareInputs) -> PrepareOutputs:
|
|
|
2254
2367
|
"RENDER_ONLY": "true" if inp.render_only else "false",
|
|
2255
2368
|
"OKSTRA_VERSION": installed_version(),
|
|
2256
2369
|
})
|
|
2370
|
+
refresh_run_context_snapshot(ctx)
|
|
2257
2371
|
if lead_runtime == "codex":
|
|
2258
2372
|
ctx["CLAUDE_RESUME_COMMAND_PATH"] = ""
|
|
2259
2373
|
ctx["CLAUDE_RESUME_COMMAND_RELATIVE_PATH"] = ""
|
|
@@ -2328,6 +2442,8 @@ def main(argv: list[str]) -> int:
|
|
|
2328
2442
|
),
|
|
2329
2443
|
)
|
|
2330
2444
|
p.add_argument("--directive", default="")
|
|
2445
|
+
p.add_argument("--analysis-target", default="", dest="analysis_target")
|
|
2446
|
+
p.add_argument("--evidence-inputs", default="", dest="evidence_inputs_raw")
|
|
2331
2447
|
p.add_argument(
|
|
2332
2448
|
"--fix-cycle", default="", choices=["", "yes", "no"], dest="fix_cycle",
|
|
2333
2449
|
help=(
|
|
@@ -2502,6 +2618,8 @@ def main(argv: list[str]) -> int:
|
|
|
2502
2618
|
task_id=args.task_id,
|
|
2503
2619
|
task_type=args.task_type,
|
|
2504
2620
|
brief_path=brief_abs,
|
|
2621
|
+
analysis_target=args.analysis_target,
|
|
2622
|
+
evidence_inputs_raw=args.evidence_inputs_raw,
|
|
2505
2623
|
directive=args.directive,
|
|
2506
2624
|
workers_override=args.workers_override,
|
|
2507
2625
|
lead_model=args.lead_model,
|
|
@@ -193,6 +193,20 @@ def compute_and_write_run_context(
|
|
|
193
193
|
return ctx
|
|
194
194
|
|
|
195
195
|
|
|
196
|
+
def refresh_run_context_snapshot(ctx: dict) -> None:
|
|
197
|
+
"""Rewrite a run context after prepare resolves run-scoped inputs."""
|
|
198
|
+
payload = compact_run_context(ctx)
|
|
199
|
+
payload["analysis"] = {
|
|
200
|
+
"sourceCommit": ctx.get("ANALYSIS_SOURCE_COMMIT", ""),
|
|
201
|
+
"target": json.loads(ctx.get("ANALYSIS_TARGET_JSON", "{}")),
|
|
202
|
+
"evidenceInputs": json.loads(ctx.get("EVIDENCE_INPUTS_JSON", "[]")),
|
|
203
|
+
}
|
|
204
|
+
payload["ANALYSIS_SOURCE_COMMIT"] = ctx.get("ANALYSIS_SOURCE_COMMIT", "")
|
|
205
|
+
payload["ANALYSIS_TARGET_JSON"] = ctx.get("ANALYSIS_TARGET_JSON", "{}")
|
|
206
|
+
payload["EVIDENCE_INPUTS_JSON"] = ctx.get("EVIDENCE_INPUTS_JSON", "[]")
|
|
207
|
+
_atomic_write_json(Path(ctx["RUN_CONTEXT_FILE"]), payload)
|
|
208
|
+
|
|
209
|
+
|
|
196
210
|
def write_run_inputs(
|
|
197
211
|
*,
|
|
198
212
|
project_root: Path,
|
|
@@ -207,7 +221,7 @@ def write_run_inputs(
|
|
|
207
221
|
inputs schema (모든 키 optional):
|
|
208
222
|
taskBriefPath, directive, workers, leadModel, claudeModel, codexModel,
|
|
209
223
|
antigravityModel, reportWriterModel, relatedTasks, approvedPlanPath,
|
|
210
|
-
clarificationResponsePath, renderOnly
|
|
224
|
+
clarificationResponsePath, analysisTarget, evidenceInputs, renderOnly
|
|
211
225
|
"""
|
|
212
226
|
run_manifests_dir = Path(run_manifests_dir)
|
|
213
227
|
path = run_manifests_dir / _run_inputs_filename(task_type_segment, seq)
|
|
@@ -227,7 +241,25 @@ def read_run_context(run_manifests_dir: Path, task_type_segment: str,
|
|
|
227
241
|
if not path.is_file():
|
|
228
242
|
return None
|
|
229
243
|
payload = json.loads(path.read_text(encoding="utf-8"))
|
|
230
|
-
|
|
244
|
+
context = hydrate_run_context(payload)
|
|
245
|
+
analysis = payload.get("analysis")
|
|
246
|
+
if isinstance(analysis, dict):
|
|
247
|
+
target_json = payload.get("ANALYSIS_TARGET_JSON")
|
|
248
|
+
if not isinstance(target_json, str):
|
|
249
|
+
target_json = json.dumps(analysis.get("target", {}), ensure_ascii=False)
|
|
250
|
+
evidence_json = payload.get("EVIDENCE_INPUTS_JSON")
|
|
251
|
+
if not isinstance(evidence_json, str):
|
|
252
|
+
evidence_json = json.dumps(
|
|
253
|
+
analysis.get("evidenceInputs", []), ensure_ascii=False,
|
|
254
|
+
)
|
|
255
|
+
context.update({
|
|
256
|
+
"ANALYSIS_SOURCE_COMMIT": str(
|
|
257
|
+
payload.get("ANALYSIS_SOURCE_COMMIT", analysis.get("sourceCommit", ""))
|
|
258
|
+
),
|
|
259
|
+
"ANALYSIS_TARGET_JSON": target_json,
|
|
260
|
+
"EVIDENCE_INPUTS_JSON": evidence_json,
|
|
261
|
+
})
|
|
262
|
+
return context
|
|
231
263
|
|
|
232
264
|
|
|
233
265
|
def read_run_inputs(run_manifests_dir: Path, task_type_segment: str,
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
"""Build a task-type-scoped excerpt of the final-report schema.
|
|
2
2
|
|
|
3
3
|
The full schema (``schemas/final-report-v1.0.schema.json``) carries the
|
|
4
|
-
deliverable property blocks for ALL task-types (``errorAnalysis``,
|
|
5
|
-
``implementationPlanning``, ``releaseHandoff``,
|
|
6
|
-
``finalVerification``) plus a
|
|
4
|
+
deliverable property blocks for ALL task-types (``errorAnalysis``, the three
|
|
5
|
+
read-only analysis blocks, ``implementationPlanning``, ``releaseHandoff``,
|
|
6
|
+
``implementation``, and ``finalVerification``) plus a
|
|
7
7
|
``$defs`` library (~38% of the file) shared across them. A single run only
|
|
8
8
|
authors ONE task-type's data.json, so the report-writer worker only needs
|
|
9
9
|
the common structure + its own task-type's block + the ``$defs`` those
|
|
@@ -27,15 +27,21 @@ import re
|
|
|
27
27
|
# task-type → the per-type deliverable property key it owns. task-types
|
|
28
28
|
# absent from this map (requirements-discovery,
|
|
29
29
|
# improvement-discovery) have no per-type block; their excerpt keeps only
|
|
30
|
-
# the common properties.
|
|
30
|
+
# the non-analysis common properties.
|
|
31
31
|
_TASK_TYPE_PROPERTY = {
|
|
32
32
|
"error-analysis": "errorAnalysis",
|
|
33
|
+
"project-analysis": "projectAnalysis",
|
|
34
|
+
"feature-analysis": "featureAnalysis",
|
|
35
|
+
"change-impact-analysis": "changeImpactAnalysis",
|
|
33
36
|
"implementation-planning": "implementationPlanning",
|
|
34
37
|
"release-handoff": "releaseHandoff",
|
|
35
38
|
"implementation": "implementation",
|
|
36
39
|
"final-verification": "finalVerification",
|
|
37
40
|
}
|
|
38
41
|
_ALL_PER_TYPE_PROPERTIES = frozenset(_TASK_TYPE_PROPERTY.values())
|
|
42
|
+
_ANALYSIS_COMMON_TASK_TYPES = frozenset(
|
|
43
|
+
{"project-analysis", "feature-analysis", "change-impact-analysis"}
|
|
44
|
+
)
|
|
39
45
|
|
|
40
46
|
_REF_RE = re.compile(r'"\$ref"\s*:\s*"#/\$defs/([^"]+)"')
|
|
41
47
|
|
|
@@ -100,6 +106,8 @@ def build_schema_excerpt(schema: dict, task_type: str, cut_from_version: str = "
|
|
|
100
106
|
"""
|
|
101
107
|
keep_per_type = _TASK_TYPE_PROPERTY.get(task_type)
|
|
102
108
|
drop_props = _ALL_PER_TYPE_PROPERTIES - ({keep_per_type} if keep_per_type else set())
|
|
109
|
+
if task_type not in _ANALYSIS_COMMON_TASK_TYPES:
|
|
110
|
+
drop_props = drop_props | {"analysisCommon"}
|
|
103
111
|
|
|
104
112
|
props = {
|
|
105
113
|
k: v for k, v in schema.get("properties", {}).items() if k not in drop_props
|
|
@@ -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
|
|