okstra 0.125.6 → 0.128.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/docs/architecture/storage-model.md +10 -3
- package/docs/architecture.md +14 -2
- package/docs/cli.md +1 -0
- package/docs/project-structure-overview.md +9 -6
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/agents/workers/report-writer-worker.md +1 -1
- package/runtime/bin/okstra-antigravity-exec.sh +8 -2
- package/runtime/bin/okstra-claude-exec.sh +8 -2
- package/runtime/bin/okstra-codex-exec.sh +65 -8
- package/runtime/prompts/lead/adapters/claude-code.md +1 -0
- package/runtime/prompts/lead/convergence.md +8 -1
- package/runtime/prompts/lead/okstra-lead-contract.md +26 -1
- package/runtime/prompts/lead/report-writer.md +1 -1
- package/runtime/prompts/lead/team-contract.md +14 -3
- package/runtime/prompts/profiles/final-verification.md +10 -6
- package/runtime/python/okstra_ctl/analysis_packet.py +7 -2
- package/runtime/python/okstra_ctl/codex_dispatch.py +27 -1
- package/runtime/python/okstra_ctl/design_surfaces.py +30 -14
- package/runtime/python/okstra_ctl/dispatch_core.py +20 -0
- package/runtime/python/okstra_ctl/log_report.py +44 -5
- package/runtime/python/okstra_ctl/path_hints.py +5 -0
- package/runtime/python/okstra_ctl/render.py +22 -0
- package/runtime/python/okstra_ctl/render_final_report.py +71 -5
- package/runtime/python/okstra_ctl/run.py +69 -3
- package/runtime/python/okstra_ctl/schema_excerpt.py +17 -1
- package/runtime/python/okstra_ctl/wizard.py +15 -4
- package/runtime/python/okstra_ctl/work_categories.py +37 -0
- package/runtime/python/okstra_ctl/worker_heartbeat.py +50 -0
- package/runtime/python/okstra_ctl/worker_liveness.py +140 -0
- package/runtime/python/okstra_ctl/worker_prompt_contract.py +223 -0
- package/runtime/python/okstra_ctl/worker_prompt_headers.py +51 -2
- package/runtime/templates/reports/final-report.template.md +9 -9
- package/runtime/templates/worker-prompt-preamble.md +13 -5
- package/runtime/validators/forbidden_actions.py +43 -4
- package/runtime/validators/validate-implementation-plan-stages.py +6 -4
- package/runtime/validators/validate-report-views.py +4 -4
- package/runtime/validators/validate-run.py +61 -6
- package/runtime/validators/validate-schedule.py +6 -4
- package/runtime/validators/validate_fanout.py +4 -3
- package/runtime/validators/validate_improvement_report.py +6 -4
- package/runtime/validators/validate_session_conformance.py +16 -7
- package/src/cli-registry.mjs +7 -0
- package/src/commands/inspect/log-report.mjs +5 -3
- package/src/commands/inspect/worker-liveness.mjs +31 -0
- package/src/commands/lifecycle/preflight.mjs +25 -2
- package/src/lib/helper-scripts.mjs +23 -0
- package/src/lib/python-helper.mjs +1 -1
|
@@ -41,8 +41,7 @@ PROFILE_SECTIONS_BY_TASK_TYPE = {
|
|
|
41
41
|
"Self-review pass before finalising the report",
|
|
42
42
|
),
|
|
43
43
|
"final-verification": (
|
|
44
|
-
"
|
|
45
|
-
"Self-review pass before finalising the report",
|
|
44
|
+
"Worker verification procedure",
|
|
46
45
|
),
|
|
47
46
|
"improvement-discovery": (
|
|
48
47
|
"Phase 1.5 — Lead reflect-back grilling",
|
|
@@ -81,6 +80,7 @@ def build_analysis_packet(
|
|
|
81
80
|
parts.extend(
|
|
82
81
|
_intro_block(
|
|
83
82
|
task_key,
|
|
83
|
+
task_type,
|
|
84
84
|
instruction_set_relative_path,
|
|
85
85
|
bool(clarification_response_path),
|
|
86
86
|
)
|
|
@@ -113,6 +113,7 @@ def _packet_frontmatter(brief_text: str, task_key: str) -> str:
|
|
|
113
113
|
|
|
114
114
|
def _intro_block(
|
|
115
115
|
task_key: str,
|
|
116
|
+
task_type: str,
|
|
116
117
|
instruction_set: str,
|
|
117
118
|
has_clarification: bool,
|
|
118
119
|
) -> list[str]:
|
|
@@ -132,6 +133,10 @@ def _intro_block(
|
|
|
132
133
|
f"- Analysis material: `{instruction_set}/analysis-material.md`",
|
|
133
134
|
f"- Reference expectations: `{instruction_set}/reference-expectations.md`",
|
|
134
135
|
]
|
|
136
|
+
if task_type == "final-verification":
|
|
137
|
+
lines.append(
|
|
138
|
+
f"- Verification target: `{instruction_set}/verification-target.md`"
|
|
139
|
+
)
|
|
135
140
|
if has_clarification:
|
|
136
141
|
lines.append(
|
|
137
142
|
f"- Clarification response: `{instruction_set}/clarification-response.md`"
|
|
@@ -27,6 +27,7 @@ from .path_hints import hydrate_active_run_context
|
|
|
27
27
|
from .wrapper_status import status_path_for_prompt
|
|
28
28
|
from .paths import task_dir
|
|
29
29
|
from .worker_prompt_headers import WorkerPromptHeaderError, worker_prompt_headers
|
|
30
|
+
from .worker_prompt_contract import validate_final_verification_prompt_paths
|
|
30
31
|
|
|
31
32
|
|
|
32
33
|
SUPPORTED_CLI_WORKERS = {
|
|
@@ -182,6 +183,7 @@ def build_dispatch_plan(
|
|
|
182
183
|
)
|
|
183
184
|
for worker_id in selected_workers
|
|
184
185
|
)
|
|
186
|
+
_validate_initial_final_verification_prompts(manifest, workers)
|
|
185
187
|
return DispatchPlan(
|
|
186
188
|
project_root=project_root,
|
|
187
189
|
workspace_root=workspace_root,
|
|
@@ -193,6 +195,23 @@ def build_dispatch_plan(
|
|
|
193
195
|
)
|
|
194
196
|
|
|
195
197
|
|
|
198
|
+
def _validate_initial_final_verification_prompts(
|
|
199
|
+
manifest: Mapping[str, Any],
|
|
200
|
+
workers: Sequence[WorkerDispatch],
|
|
201
|
+
) -> None:
|
|
202
|
+
if manifest.get("taskType") != "final-verification":
|
|
203
|
+
return
|
|
204
|
+
prompt_paths = {
|
|
205
|
+
worker.worker_id: worker.prompt_path
|
|
206
|
+
for worker in workers
|
|
207
|
+
if worker.worker_id != REPORT_WRITER_WORKER_ID
|
|
208
|
+
and "-reverify-r" not in worker.prompt_path.name
|
|
209
|
+
}
|
|
210
|
+
errors = validate_final_verification_prompt_paths(prompt_paths)
|
|
211
|
+
if errors:
|
|
212
|
+
raise DispatchError("final-verification prompt contract: " + "; ".join(errors))
|
|
213
|
+
|
|
214
|
+
|
|
196
215
|
def dispatch_plan(plan: DispatchPlan) -> int:
|
|
197
216
|
_set_dispatch_mode(plan.team_state_path, _dispatch_mode(plan.workers))
|
|
198
217
|
for worker in plan.workers:
|
|
@@ -702,6 +721,13 @@ def _analysis_input_lines(
|
|
|
702
721
|
manifest: Mapping[str, Any],
|
|
703
722
|
active_context: Mapping[str, Any],
|
|
704
723
|
) -> list[str]:
|
|
724
|
+
if manifest.get("taskType") == "final-verification":
|
|
725
|
+
packet_path = _instruction_path(
|
|
726
|
+
manifest,
|
|
727
|
+
active_context,
|
|
728
|
+
"analysisPacketPath",
|
|
729
|
+
)
|
|
730
|
+
return _existing_input_lines((("Primary analysis packet", packet_path),))
|
|
705
731
|
inputs = [
|
|
706
732
|
("Primary analysis packet", _instruction_path(manifest, active_context, "analysisPacketPath")),
|
|
707
733
|
("Task brief", _instruction_path(manifest, active_context, "taskBriefPath")),
|
|
@@ -800,7 +826,7 @@ def _worktree_headers(
|
|
|
800
826
|
) -> list[str]:
|
|
801
827
|
task_type = _require_string(manifest, "taskType")
|
|
802
828
|
worktree = _worktree_path(manifest, active_context)
|
|
803
|
-
if task_type
|
|
829
|
+
if task_type != "implementation" or not worktree:
|
|
804
830
|
return []
|
|
805
831
|
headers = [f"**Worktree:** {worktree}"]
|
|
806
832
|
if role == "executor":
|
|
@@ -180,11 +180,28 @@ def _stage_rows(planning: Mapping[str, Any]) -> dict[int, Mapping[str, Any]]:
|
|
|
180
180
|
return rows
|
|
181
181
|
|
|
182
182
|
|
|
183
|
-
|
|
183
|
+
_LINE_RANGE_SUFFIX = re.compile(r":\d+(?:-\d+)?$")
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _stages_for_selected_path(
|
|
184
187
|
path: str,
|
|
185
188
|
stages: Mapping[int, Mapping[str, Any]],
|
|
186
|
-
) -> int:
|
|
187
|
-
|
|
189
|
+
) -> list[int]:
|
|
190
|
+
"""Every stage whose stepwise ``files`` cells touch *path*.
|
|
191
|
+
|
|
192
|
+
A file legitimately edited by more than one stage — the task-level
|
|
193
|
+
``qa/conformance-manifest.json`` is the standard case, since each
|
|
194
|
+
conformance-declaring stage registers its own entry — yields one design
|
|
195
|
+
surface per stage, which is what the consumer keys on. Requiring a single
|
|
196
|
+
owner instead would force the plan to hide an edit, collapse stage
|
|
197
|
+
boundaries, or re-add the line-range suffixes that used to disambiguate
|
|
198
|
+
stages by accident.
|
|
199
|
+
|
|
200
|
+
Zero stages is still an error: the recommended option declares a file that
|
|
201
|
+
no step creates or edits.
|
|
202
|
+
"""
|
|
203
|
+
normalised = _LINE_RANGE_SUFFIX.sub("", _normalise(path))
|
|
204
|
+
path_pattern = rf"(?<![\w./@-]){re.escape(normalised)}(?![\w./@-])"
|
|
188
205
|
matched = []
|
|
189
206
|
for stage_number, stage in stages.items():
|
|
190
207
|
files = [
|
|
@@ -192,14 +209,13 @@ def _stage_for_selected_path(
|
|
|
192
209
|
for step in stage.get("stepwiseExecution") or []
|
|
193
210
|
if isinstance(step, Mapping)
|
|
194
211
|
]
|
|
195
|
-
path_pattern = rf"(?<![\w./@-]){re.escape(normalised)}(?![\w./@-])"
|
|
196
212
|
if any(re.search(path_pattern, cell) is not None for cell in files):
|
|
197
213
|
matched.append(stage_number)
|
|
198
|
-
if
|
|
214
|
+
if not matched:
|
|
199
215
|
raise DesignSurfaceError(
|
|
200
|
-
f"selected option path {path!r} is not mapped to a stage
|
|
216
|
+
f"selected option path {path!r} is not mapped to a stage"
|
|
201
217
|
)
|
|
202
|
-
return matched
|
|
218
|
+
return matched
|
|
203
219
|
|
|
204
220
|
|
|
205
221
|
def detect_design_surfaces(
|
|
@@ -226,13 +242,13 @@ def detect_design_surfaces(
|
|
|
226
242
|
if not isinstance(row, Mapping):
|
|
227
243
|
continue
|
|
228
244
|
path = str(row.get("path") or "")
|
|
229
|
-
stage_number
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
245
|
+
for stage_number in _stages_for_selected_path(path, stages):
|
|
246
|
+
for kind, evidence in _matching_rule_evidence(
|
|
247
|
+
step=None,
|
|
248
|
+
field="files",
|
|
249
|
+
value=path,
|
|
250
|
+
):
|
|
251
|
+
grouped.setdefault((stage_number, kind), []).append(evidence)
|
|
236
252
|
rule_order = {rule.kind: index for index, rule in enumerate(RULES)}
|
|
237
253
|
return [
|
|
238
254
|
DesignSurfaceTrigger(stage, kind, tuple(grouped[(stage, kind)]))
|
|
@@ -18,6 +18,7 @@ from .final_report_paths import (
|
|
|
18
18
|
from .lead_events import LeadEvent, append_lead_event
|
|
19
19
|
from .path_hints import hydrate_active_run_context
|
|
20
20
|
from .worker_prompt_headers import WorkerPromptHeaderError, worker_prompt_headers
|
|
21
|
+
from .worker_prompt_contract import validate_final_verification_prompt_paths
|
|
21
22
|
from .wrapper_status import read_wrapper_status, status_path_for_prompt
|
|
22
23
|
|
|
23
24
|
|
|
@@ -183,6 +184,7 @@ def build_dispatch_plan(
|
|
|
183
184
|
report_writer_model,
|
|
184
185
|
options,
|
|
185
186
|
)
|
|
187
|
+
_validate_initial_final_verification_prompts(manifest, jobs)
|
|
186
188
|
return DispatchPlan(
|
|
187
189
|
project_root=project_root,
|
|
188
190
|
workspace_root=workspace_root.resolve(),
|
|
@@ -195,6 +197,24 @@ def build_dispatch_plan(
|
|
|
195
197
|
)
|
|
196
198
|
|
|
197
199
|
|
|
200
|
+
def _validate_initial_final_verification_prompts(
|
|
201
|
+
manifest: Mapping[str, Any],
|
|
202
|
+
jobs: Sequence[WorkerJob],
|
|
203
|
+
) -> None:
|
|
204
|
+
if manifest.get("taskType") != "final-verification":
|
|
205
|
+
return
|
|
206
|
+
prompt_paths = {
|
|
207
|
+
job.worker_id: job.prompt_path
|
|
208
|
+
for job in jobs
|
|
209
|
+
if job.worker_id != REPORT_WRITER_WORKER_ID
|
|
210
|
+
and job.dispatch_kind == "initial"
|
|
211
|
+
and "-reverify-r" not in job.prompt_path.name
|
|
212
|
+
}
|
|
213
|
+
errors = validate_final_verification_prompt_paths(prompt_paths)
|
|
214
|
+
if errors:
|
|
215
|
+
raise DispatchError("final-verification prompt contract: " + "; ".join(errors))
|
|
216
|
+
|
|
217
|
+
|
|
198
218
|
def dispatch_plan(plan: DispatchPlan, *, wait: bool = True) -> int:
|
|
199
219
|
if wait:
|
|
200
220
|
if any(job.backend == BACKEND_TMUX_PANE for job in plan.jobs):
|
|
@@ -53,6 +53,34 @@ def _per_task(files: list[dict]) -> list[dict]:
|
|
|
53
53
|
return sorted(acc.values(), key=lambda a: a["totalBytes"], reverse=True)
|
|
54
54
|
|
|
55
55
|
|
|
56
|
+
def _prompt_pair(log_path: Path, transcript_bytes: int) -> dict:
|
|
57
|
+
prompt_path = log_path.with_suffix(".md")
|
|
58
|
+
if not prompt_path.is_file():
|
|
59
|
+
return {
|
|
60
|
+
"transcriptPath": str(log_path),
|
|
61
|
+
"transcriptBytes": transcript_bytes,
|
|
62
|
+
"promptPath": "",
|
|
63
|
+
"promptBytes": 0,
|
|
64
|
+
"transcriptToPromptRatio": None,
|
|
65
|
+
}
|
|
66
|
+
try:
|
|
67
|
+
prompt_bytes = prompt_path.stat().st_size
|
|
68
|
+
except OSError:
|
|
69
|
+
prompt_bytes = 0
|
|
70
|
+
ratio = (
|
|
71
|
+
round(transcript_bytes / prompt_bytes, 2)
|
|
72
|
+
if prompt_bytes > 0
|
|
73
|
+
else None
|
|
74
|
+
)
|
|
75
|
+
return {
|
|
76
|
+
"transcriptPath": str(log_path),
|
|
77
|
+
"transcriptBytes": transcript_bytes,
|
|
78
|
+
"promptPath": str(prompt_path),
|
|
79
|
+
"promptBytes": prompt_bytes,
|
|
80
|
+
"transcriptToPromptRatio": ratio,
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
|
|
56
84
|
def scan_logs(logs_root: Path, project_root: Path, top: int = 20) -> dict:
|
|
57
85
|
project_id = _project_id(project_root)
|
|
58
86
|
files: list[dict] = []
|
|
@@ -68,16 +96,27 @@ def scan_logs(logs_root: Path, project_root: Path, top: int = 20) -> dict:
|
|
|
68
96
|
continue
|
|
69
97
|
meta = _parse(parts, p.stem)
|
|
70
98
|
tk = f"{project_id}:{meta['taskGroup']}:{meta['taskId']}" if meta["taskGroup"] else ""
|
|
71
|
-
files.append({
|
|
72
|
-
|
|
99
|
+
files.append({
|
|
100
|
+
"path": str(p),
|
|
101
|
+
"sizeBytes": size,
|
|
102
|
+
"mtimeEpoch": mtime,
|
|
103
|
+
"taskKey": tk,
|
|
104
|
+
**meta,
|
|
105
|
+
**_prompt_pair(p, size),
|
|
106
|
+
})
|
|
73
107
|
files.sort(key=lambda f: f["sizeBytes"], reverse=True)
|
|
74
108
|
return {
|
|
75
109
|
"logsRoot": str(logs_root),
|
|
76
110
|
"topLargest": files[:top],
|
|
77
111
|
"perTask": _per_task(files),
|
|
78
|
-
"totals": {
|
|
79
|
-
|
|
80
|
-
|
|
112
|
+
"totals": {
|
|
113
|
+
"fileCount": len(files),
|
|
114
|
+
"totalBytes": sum(f["sizeBytes"] for f in files),
|
|
115
|
+
"taskCount": len({f["taskKey"] for f in files if f["taskKey"]}),
|
|
116
|
+
"promptBytes": sum(f["promptBytes"] for f in files),
|
|
117
|
+
"transcriptBytes": sum(f["transcriptBytes"] for f in files),
|
|
118
|
+
"pairedFileCount": sum(1 for f in files if f["promptPath"]),
|
|
119
|
+
},
|
|
81
120
|
}
|
|
82
121
|
|
|
83
122
|
|
|
@@ -59,6 +59,7 @@ def compact_active_run_context(
|
|
|
59
59
|
"inputs": _compact_active_inputs(payload),
|
|
60
60
|
"workers": _compact_active_workers(payload),
|
|
61
61
|
"executorWorktree": dict(_mapping(payload.get("executorWorktree"))),
|
|
62
|
+
"verificationTarget": dict(_mapping(payload.get("verificationTarget"))),
|
|
62
63
|
"lazyReadPlan": dict(_mapping(payload.get("lazyReadPlan"))),
|
|
63
64
|
}
|
|
64
65
|
|
|
@@ -99,6 +100,7 @@ def hydrate_active_run_context(payload: Mapping[str, Any]) -> dict[str, Any]:
|
|
|
99
100
|
"codingPreflightDir": ctx.get("OKSTRA_CODING_PREFLIGHT_DIR", ""),
|
|
100
101
|
},
|
|
101
102
|
"executorWorktree": dict(_mapping(payload.get("executorWorktree"))),
|
|
103
|
+
"verificationTarget": dict(_mapping(payload.get("verificationTarget"))),
|
|
102
104
|
"sourceArtifacts": _hydrate_active_source_artifacts(ctx),
|
|
103
105
|
"lazyReadPlan": dict(_mapping(payload.get("lazyReadPlan"))),
|
|
104
106
|
}
|
|
@@ -190,6 +192,7 @@ def _hydrate_active_instruction_set(
|
|
|
190
192
|
clarification_response = ""
|
|
191
193
|
if inputs.get("hasClarificationResponse"):
|
|
192
194
|
clarification_response = f"{instruction_set}/clarification-response.md"
|
|
195
|
+
target = _mapping(payload.get("verificationTarget"))
|
|
193
196
|
return {
|
|
194
197
|
"path": instruction_set,
|
|
195
198
|
"analysisPacketPath": ctx.get("ANALYSIS_PACKET_RELATIVE_PATH", ""),
|
|
@@ -200,6 +203,8 @@ def _hydrate_active_instruction_set(
|
|
|
200
203
|
"clarificationResponsePath": clarification_response,
|
|
201
204
|
"finalReportTemplatePath": ctx.get("FINAL_REPORT_TEMPLATE_RELATIVE_PATH", ""),
|
|
202
205
|
"finalReportSchemaPath": ctx.get("FINAL_REPORT_SCHEMA_RELATIVE_PATH", ""),
|
|
206
|
+
"verificationTargetPath": str(target.get("path") or ""),
|
|
207
|
+
"verificationTargetDigest": str(target.get("digest") or ""),
|
|
203
208
|
}
|
|
204
209
|
|
|
205
210
|
|
|
@@ -354,6 +354,8 @@ def _active_instruction_set(ctx: dict) -> dict:
|
|
|
354
354
|
"clarificationResponsePath": clarification,
|
|
355
355
|
"finalReportTemplatePath": ctx.get("FINAL_REPORT_TEMPLATE_RELATIVE_PATH", ""),
|
|
356
356
|
"finalReportSchemaPath": ctx.get("FINAL_REPORT_SCHEMA_RELATIVE_PATH", ""),
|
|
357
|
+
"verificationTargetPath": ctx.get("VERIFICATION_TARGET_RELATIVE_PATH", ""),
|
|
358
|
+
"verificationTargetDigest": ctx.get("VERIFICATION_TARGET_DIGEST", ""),
|
|
357
359
|
}
|
|
358
360
|
|
|
359
361
|
|
|
@@ -387,6 +389,19 @@ def _active_executor_worktree(ctx: dict) -> dict:
|
|
|
387
389
|
}
|
|
388
390
|
|
|
389
391
|
|
|
392
|
+
def _active_verification_target(ctx: dict) -> dict:
|
|
393
|
+
if ctx.get("TASK_TYPE") != "final-verification":
|
|
394
|
+
return {}
|
|
395
|
+
return {
|
|
396
|
+
"scope": ctx.get("VERIFICATION_SCOPE", ""),
|
|
397
|
+
"worktreePath": ctx.get("VERIFICATION_WORKTREE_PATH", ""),
|
|
398
|
+
"baseRef": ctx.get("VERIFICATION_BASE_REF", ""),
|
|
399
|
+
"headRef": ctx.get("VERIFICATION_HEAD_REF", ""),
|
|
400
|
+
"path": ctx.get("VERIFICATION_TARGET_RELATIVE_PATH", ""),
|
|
401
|
+
"digest": ctx.get("VERIFICATION_TARGET_DIGEST", ""),
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
|
|
390
405
|
def _active_source_artifacts(ctx: dict) -> dict:
|
|
391
406
|
return {
|
|
392
407
|
"taskManifestPath": ctx.get("TASK_MANIFEST_RELATIVE_PATH", ""),
|
|
@@ -429,6 +444,7 @@ def render_active_run_context(active_context_path: str, ctx: dict) -> None:
|
|
|
429
444
|
"errorLogs": _active_error_logs(ctx),
|
|
430
445
|
"runtimeResources": _active_runtime_resources(ctx),
|
|
431
446
|
"executorWorktree": _active_executor_worktree(ctx),
|
|
447
|
+
"verificationTarget": _active_verification_target(ctx),
|
|
432
448
|
"sourceArtifacts": _active_source_artifacts(ctx),
|
|
433
449
|
"lazyReadPlan": _active_lazy_read_plan(),
|
|
434
450
|
}
|
|
@@ -1053,6 +1069,10 @@ def render_task_manifest(manifest_path: str, ctx: dict) -> None:
|
|
|
1053
1069
|
"analysisMaterialPath": ctx.get("INSTRUCTION_SET_RELATIVE_PATH", "")
|
|
1054
1070
|
+ "/analysis-material.md",
|
|
1055
1071
|
"analysisPacketPath": ctx.get("ANALYSIS_PACKET_RELATIVE_PATH", ""),
|
|
1072
|
+
"verificationTargetPath": ctx.get(
|
|
1073
|
+
"VERIFICATION_TARGET_RELATIVE_PATH", ""
|
|
1074
|
+
),
|
|
1075
|
+
"verificationTargetDigest": ctx.get("VERIFICATION_TARGET_DIGEST", ""),
|
|
1056
1076
|
"taskBriefCopyPath": ctx.get("INSTRUCTION_SET_RELATIVE_PATH", "")
|
|
1057
1077
|
+ "/task-brief.md",
|
|
1058
1078
|
"referenceExpectationsPath": ctx.get(
|
|
@@ -1278,6 +1298,8 @@ def render_run_manifest(run_manifest_path: str, ctx: dict) -> None:
|
|
|
1278
1298
|
"teamStatePath": ctx.get("TEAM_STATE_RELATIVE_PATH", ""),
|
|
1279
1299
|
"activeRunContextPath": ctx.get("ACTIVE_RUN_CONTEXT_RELATIVE_PATH", ""),
|
|
1280
1300
|
"analysisPacketPath": ctx.get("ANALYSIS_PACKET_RELATIVE_PATH", ""),
|
|
1301
|
+
"verificationTargetPath": ctx.get("VERIFICATION_TARGET_RELATIVE_PATH", ""),
|
|
1302
|
+
"verificationTargetDigest": ctx.get("VERIFICATION_TARGET_DIGEST", ""),
|
|
1281
1303
|
"workerResultsDirectoryPath": ctx.get("WORKER_RESULTS_RELATIVE_PATH", ""),
|
|
1282
1304
|
"reportTemplatePath": ctx.get("FINAL_REPORT_TEMPLATE_RELATIVE_PATH", ""),
|
|
1283
1305
|
"validatorScriptPath": ctx.get("RUN_VALIDATOR_RELATIVE_PATH", ""),
|
|
@@ -52,6 +52,8 @@ from okstra_ctl.final_report_schema import SchemaError, load_schema, validate as
|
|
|
52
52
|
from okstra_ctl.i18n import I18nError, SUPPORTED_LANGS, load_dictionary, make_jinja_global
|
|
53
53
|
from okstra_ctl.md_table import UNESCAPED_PIPE_RE, escape_pipes
|
|
54
54
|
from okstra_ctl.models import UnknownModelError, resolve_model_metadata
|
|
55
|
+
from okstra_ctl.schema_excerpt import excerpt_cut_from_version
|
|
56
|
+
from okstra_ctl.seeding import installed_version
|
|
55
57
|
|
|
56
58
|
|
|
57
59
|
DEFAULT_TEMPLATE_REL = ("templates", "reports", "final-report.template.md")
|
|
@@ -125,6 +127,21 @@ def _yaml_inline_list(values: list[str]) -> str:
|
|
|
125
127
|
return "[" + ", ".join(_yaml_scalar(v) for v in values) + "]"
|
|
126
128
|
|
|
127
129
|
|
|
130
|
+
def _md_quote(value: str | None, width: int = 2) -> str:
|
|
131
|
+
"""Re-mark every continuation line of a multi-line blockquote value.
|
|
132
|
+
|
|
133
|
+
The template supplies the first line's `` > ``; lines 2..n arrive at
|
|
134
|
+
column 0, where they end the enclosing list item and drag the bullets
|
|
135
|
+
that follow into one run-on paragraph.
|
|
136
|
+
"""
|
|
137
|
+
text = "" if value is None else str(value)
|
|
138
|
+
pad = " " * width
|
|
139
|
+
lines = text.split("\n")
|
|
140
|
+
return "\n".join(
|
|
141
|
+
[lines[0]] + [f"{pad}>{' ' + line if line else ''}" for line in lines[1:]]
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
|
|
128
145
|
# --- Index / scroll-anchor post-render pass -------------------------------
|
|
129
146
|
# After Jinja2 renders the body, every report gets a top-of-report index and
|
|
130
147
|
# clickable scroll anchors. An ID is *defined* when it is the leading token of
|
|
@@ -424,6 +441,11 @@ def _build_environment(template_dir: Path) -> Environment:
|
|
|
424
441
|
# cannot split a markdown table row. Table-cell interpolations only —
|
|
425
442
|
# never code blocks / headings / prose, where `\|` would render verbatim.
|
|
426
443
|
env.filters["mdcell"] = escape_pipes
|
|
444
|
+
# `mdquote` re-marks continuation lines of a blockquote nested in a list
|
|
445
|
+
# item; the fenced siblings use Jinja's builtin `indent` for the same
|
|
446
|
+
# reason. Both exist because only the first line of an interpolation
|
|
447
|
+
# inherits the template's leading indent.
|
|
448
|
+
env.filters["mdquote"] = _md_quote
|
|
427
449
|
return env
|
|
428
450
|
|
|
429
451
|
|
|
@@ -516,6 +538,47 @@ def find_default_template(start: Path | None = None) -> Path:
|
|
|
516
538
|
)
|
|
517
539
|
|
|
518
540
|
|
|
541
|
+
def _bundle_excerpt_path(data_path: Path) -> Path | None:
|
|
542
|
+
"""The task bundle's schema excerpt, found by walking up from *data_path*."""
|
|
543
|
+
for ancestor in data_path.resolve().parents:
|
|
544
|
+
candidate = ancestor / "instruction-set" / "final-report-schema.json"
|
|
545
|
+
if candidate.is_file():
|
|
546
|
+
return candidate
|
|
547
|
+
return None
|
|
548
|
+
|
|
549
|
+
|
|
550
|
+
def _with_excerpt_drift_hint(
|
|
551
|
+
exc: FinalReportRenderError, data_path: Path
|
|
552
|
+
) -> FinalReportRenderError:
|
|
553
|
+
"""Name the version skew behind a schema failure, when that is the cause.
|
|
554
|
+
|
|
555
|
+
A long run straddles its own runtime upgrade: the report-writer authors
|
|
556
|
+
against the excerpt frozen into the bundle at prep time, while the renderer
|
|
557
|
+
validates against the installed schema. Without this the author sees only
|
|
558
|
+
`additional property ... not allowed` for a field the excerpt told it to
|
|
559
|
+
write, and has no way to tell a real mistake from a stale bundle.
|
|
560
|
+
"""
|
|
561
|
+
if "schema validation" not in str(exc):
|
|
562
|
+
return exc
|
|
563
|
+
excerpt_path = _bundle_excerpt_path(data_path)
|
|
564
|
+
if excerpt_path is None:
|
|
565
|
+
return exc
|
|
566
|
+
try:
|
|
567
|
+
cut_from = excerpt_cut_from_version(
|
|
568
|
+
json.loads(excerpt_path.read_text(encoding="utf-8"))
|
|
569
|
+
)
|
|
570
|
+
except (OSError, json.JSONDecodeError):
|
|
571
|
+
return exc
|
|
572
|
+
current = installed_version()
|
|
573
|
+
if not cut_from or not current or cut_from == current:
|
|
574
|
+
return exc
|
|
575
|
+
return FinalReportRenderError(
|
|
576
|
+
f"{exc} — the bundle's schema excerpt ({excerpt_path}) was cut from okstra "
|
|
577
|
+
f"{cut_from} but validation ran on {current}; author against the installed "
|
|
578
|
+
"schema, not the excerpt, or re-prepare the bundle."
|
|
579
|
+
)
|
|
580
|
+
|
|
581
|
+
|
|
519
582
|
def render_to_file(
|
|
520
583
|
data_path: Path,
|
|
521
584
|
output_path: Path,
|
|
@@ -542,11 +605,14 @@ def render_to_file(
|
|
|
542
605
|
# 설치본에서 항상 'could not locate template' 으로 실패한다(OKSTRA_HOME 을
|
|
543
606
|
# 수동 설정해야 했던 원인). 프로젝트별 override 는 --template 으로 한다.
|
|
544
607
|
resolved_template = template_path or find_default_template()
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
608
|
+
try:
|
|
609
|
+
rendered = render(
|
|
610
|
+
data,
|
|
611
|
+
template_path=resolved_template,
|
|
612
|
+
report_language=report_language,
|
|
613
|
+
)
|
|
614
|
+
except FinalReportRenderError as exc:
|
|
615
|
+
raise _with_excerpt_drift_hint(exc, data_path) from exc
|
|
550
616
|
|
|
551
617
|
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
552
618
|
tmp = output_path.with_suffix(output_path.suffix + f".tmp.{os.getpid()}")
|
|
@@ -16,6 +16,7 @@ state passing, and are read once at the start.
|
|
|
16
16
|
from __future__ import annotations
|
|
17
17
|
|
|
18
18
|
import importlib.util
|
|
19
|
+
import hashlib
|
|
19
20
|
import json
|
|
20
21
|
import os
|
|
21
22
|
import re
|
|
@@ -100,6 +101,7 @@ from . import implementation_stage as _implementation_stage
|
|
|
100
101
|
from .stage_reconcile import (
|
|
101
102
|
auto_reconcile_best_effort as _stage_auto_reconcile_best_effort,
|
|
102
103
|
)
|
|
104
|
+
from .work_categories import resolve_work_category
|
|
103
105
|
from .worktree import (
|
|
104
106
|
WorktreeProvision,
|
|
105
107
|
provision_task_worktree,
|
|
@@ -1578,20 +1580,70 @@ def _reserve_final_verification_target(
|
|
|
1578
1580
|
|
|
1579
1581
|
diff_stat = _git_out(target.worktree_path, "diff", "--stat",
|
|
1580
1582
|
f"{target.base}..{target.head}")
|
|
1583
|
+
ctx["VERIFICATION_SCOPE"] = target.scope
|
|
1584
|
+
ctx["VERIFICATION_WORKTREE_PATH"] = target.worktree_path
|
|
1585
|
+
ctx["VERIFICATION_BASE_REF"] = target.base
|
|
1586
|
+
ctx["VERIFICATION_HEAD_REF"] = target.head
|
|
1587
|
+
ctx["VERIFICATION_TARGET"] = _format_verification_target(target, diff_stat)
|
|
1588
|
+
|
|
1589
|
+
|
|
1590
|
+
def _format_verification_target(
|
|
1591
|
+
target: _stage_targets.FinalVerificationTarget,
|
|
1592
|
+
diff_stat: str,
|
|
1593
|
+
) -> str:
|
|
1581
1594
|
reports = "\n".join(
|
|
1582
1595
|
f" - stage {s}: `{rp or '(report_path unrecorded)'}`"
|
|
1583
1596
|
for s, rp in zip(target.stages, target.reports)
|
|
1584
1597
|
)
|
|
1585
|
-
|
|
1598
|
+
return (
|
|
1586
1599
|
f"- **Verification scope:** `{target.scope}`\n"
|
|
1587
1600
|
f"- **Worktree:** `{target.worktree_path}`\n"
|
|
1588
1601
|
f"- **Verification base ref:** `{target.base}`\n"
|
|
1602
|
+
f"- **Verification head ref:** `{target.head}`\n"
|
|
1589
1603
|
f"- **Stages under verification:** {target.stages}\n"
|
|
1590
1604
|
f"- **Source implementation reports:**\n{reports}\n"
|
|
1591
1605
|
f"- **Verification diff stat:**\n```\n{diff_stat}\n```"
|
|
1592
1606
|
)
|
|
1593
1607
|
|
|
1594
1608
|
|
|
1609
|
+
def write_verification_target_snapshot(
|
|
1610
|
+
instruction_set: Path,
|
|
1611
|
+
target_markdown: str,
|
|
1612
|
+
) -> tuple[Path, str]:
|
|
1613
|
+
"""Persist one normalized final-verification target and return its digest."""
|
|
1614
|
+
normalized = target_markdown.replace("\r\n", "\n").replace("\r", "\n")
|
|
1615
|
+
normalized = normalized.rstrip() + "\n"
|
|
1616
|
+
digest = "sha256:" + hashlib.sha256(normalized.encode("utf-8")).hexdigest()
|
|
1617
|
+
path = instruction_set / "verification-target.md"
|
|
1618
|
+
path.write_text(
|
|
1619
|
+
normalized + f"- **Verification target digest:** `{digest}`\n",
|
|
1620
|
+
encoding="utf-8",
|
|
1621
|
+
)
|
|
1622
|
+
return path, digest
|
|
1623
|
+
|
|
1624
|
+
|
|
1625
|
+
def _write_verification_target_artifact(
|
|
1626
|
+
inp: PrepareInputs,
|
|
1627
|
+
ctx: dict,
|
|
1628
|
+
instruction_set: Path,
|
|
1629
|
+
) -> None:
|
|
1630
|
+
if inp.task_type != "final-verification":
|
|
1631
|
+
return
|
|
1632
|
+
target_markdown = str(ctx.get("VERIFICATION_TARGET") or "")
|
|
1633
|
+
if not target_markdown:
|
|
1634
|
+
raise PrepareError("final-verification target snapshot is missing")
|
|
1635
|
+
target_path, target_digest = write_verification_target_snapshot(
|
|
1636
|
+
instruction_set,
|
|
1637
|
+
target_markdown,
|
|
1638
|
+
)
|
|
1639
|
+
ctx["VERIFICATION_TARGET_PATH"] = str(target_path)
|
|
1640
|
+
ctx["VERIFICATION_TARGET_RELATIVE_PATH"] = (
|
|
1641
|
+
f"{ctx['INSTRUCTION_SET_RELATIVE_PATH']}/verification-target.md"
|
|
1642
|
+
)
|
|
1643
|
+
ctx["VERIFICATION_TARGET_DIGEST"] = target_digest
|
|
1644
|
+
ctx["VERIFICATION_TARGET"] = target_path.read_text(encoding="utf-8")
|
|
1645
|
+
|
|
1646
|
+
|
|
1595
1647
|
def _write_instruction_set_sources(
|
|
1596
1648
|
inp: PrepareInputs, ctx: dict, profile_content: str, review_material: str
|
|
1597
1649
|
) -> Path:
|
|
@@ -1599,6 +1651,7 @@ def _write_instruction_set_sources(
|
|
|
1599
1651
|
reference-expectations 를 기록하고 디렉터리 경로를 돌려준다."""
|
|
1600
1652
|
instruction_set = Path(ctx["INSTRUCTION_SET_PATH"])
|
|
1601
1653
|
instruction_set.mkdir(parents=True, exist_ok=True)
|
|
1654
|
+
_write_verification_target_artifact(inp, ctx, instruction_set)
|
|
1602
1655
|
profile_rendered = profile_content
|
|
1603
1656
|
if inp.task_type == "implementation":
|
|
1604
1657
|
profile_rendered += "\n\n{{DESIGN_PREP_CONTEXT}}\n\n{{FIX_RUN_CONTEXT}}"
|
|
@@ -1709,7 +1762,9 @@ def _render_lead_prompt_and_snapshot(
|
|
|
1709
1762
|
# still has the phase-stripped template + skill structure guide, and
|
|
1710
1763
|
# validation runs against the full schema regardless.
|
|
1711
1764
|
try:
|
|
1712
|
-
_excerpt = build_schema_excerpt(
|
|
1765
|
+
_excerpt = build_schema_excerpt(
|
|
1766
|
+
load_schema(), inp.task_type, installed_version()
|
|
1767
|
+
)
|
|
1713
1768
|
Path(ctx["FINAL_REPORT_SCHEMA_PATH"]).write_text(
|
|
1714
1769
|
json.dumps(_excerpt, indent=2, ensure_ascii=False) + "\n",
|
|
1715
1770
|
encoding="utf-8",
|
|
@@ -1948,6 +2003,16 @@ def prepare_task_bundle(inp: PrepareInputs) -> PrepareOutputs:
|
|
|
1948
2003
|
task_id_segment = slugify(inp.task_id)
|
|
1949
2004
|
task_key = f"{inp.project_id}:{inp.task_group}:{inp.task_id}"
|
|
1950
2005
|
|
|
2006
|
+
# Worktree branch namespace, workflow state and every rendered manifest read
|
|
2007
|
+
# the same resolved value, so a phase invoked without --work-category still
|
|
2008
|
+
# lands on the branch namespace the task was classified with.
|
|
2009
|
+
inp.work_category = resolve_work_category(
|
|
2010
|
+
inp.work_category,
|
|
2011
|
+
project_root=project_root,
|
|
2012
|
+
task_group=inp.task_group,
|
|
2013
|
+
task_id=inp.task_id,
|
|
2014
|
+
)
|
|
2015
|
+
|
|
1951
2016
|
# ---- task worktree provisioning (every phase, every task-type) ----
|
|
1952
2017
|
# One worktree per task-key: requirements-discovery, error-analysis,
|
|
1953
2018
|
# implementation-planning and implementation phases of the same task
|
|
@@ -2369,7 +2434,8 @@ def main(argv: list[str]) -> int:
|
|
|
2369
2434
|
help=(
|
|
2370
2435
|
"Work-category classification for this task "
|
|
2371
2436
|
"(bugfix / feature / refactor / ops / improvement). "
|
|
2372
|
-
"
|
|
2437
|
+
"When omitted, falls back to the category already recorded in "
|
|
2438
|
+
"task-manifest.json, then to `feature`."
|
|
2373
2439
|
),
|
|
2374
2440
|
)
|
|
2375
2441
|
p.add_argument(
|
|
@@ -72,7 +72,16 @@ def _conditional_applies(entry: dict, task_type: str) -> bool:
|
|
|
72
72
|
return task_type in (enum or [])
|
|
73
73
|
|
|
74
74
|
|
|
75
|
-
|
|
75
|
+
EXCERPT_VERSION_KEY = "x-okstraCutFromVersion"
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def excerpt_cut_from_version(excerpt: dict) -> str:
|
|
79
|
+
"""The okstra version *excerpt* was cut from; empty when unstamped."""
|
|
80
|
+
value = excerpt.get(EXCERPT_VERSION_KEY)
|
|
81
|
+
return value if isinstance(value, str) else ""
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def build_schema_excerpt(schema: dict, task_type: str, cut_from_version: str = "") -> dict:
|
|
76
85
|
"""Return a task-type-scoped copy of *schema*.
|
|
77
86
|
|
|
78
87
|
Drops the per-type property blocks that do not belong to *task_type*,
|
|
@@ -81,6 +90,11 @@ def build_schema_excerpt(schema: dict, task_type: str) -> dict:
|
|
|
81
90
|
def reachable from a kept property or conditional). Top-level metadata
|
|
82
91
|
and ``required`` are preserved (``required`` never lists per-type
|
|
83
92
|
blocks, but it is filtered defensively).
|
|
93
|
+
|
|
94
|
+
*cut_from_version* stamps the excerpt with the runtime that produced it.
|
|
95
|
+
Bundle prep and report render can be hours apart and the runtime upgrades
|
|
96
|
+
itself in between, so a mismatch is what turns an opaque "additional
|
|
97
|
+
property … not allowed" into "the bundle excerpt is from an older okstra".
|
|
84
98
|
"""
|
|
85
99
|
keep_per_type = _TASK_TYPE_PROPERTY.get(task_type)
|
|
86
100
|
drop_props = _ALL_PER_TYPE_PROPERTIES - ({keep_per_type} if keep_per_type else set())
|
|
@@ -119,4 +133,6 @@ def build_schema_excerpt(schema: dict, task_type: str) -> dict:
|
|
|
119
133
|
excerpt["allOf"] = all_of
|
|
120
134
|
if reachable:
|
|
121
135
|
excerpt["$defs"] = {k: v for k, v in defs.items() if k in reachable}
|
|
136
|
+
if cut_from_version:
|
|
137
|
+
excerpt[EXCERPT_VERSION_KEY] = cut_from_version
|
|
122
138
|
return excerpt
|