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.
Files changed (48) hide show
  1. package/docs/architecture/storage-model.md +10 -3
  2. package/docs/architecture.md +14 -2
  3. package/docs/cli.md +1 -0
  4. package/docs/project-structure-overview.md +9 -6
  5. package/package.json +1 -1
  6. package/runtime/BUILD.json +2 -2
  7. package/runtime/agents/workers/report-writer-worker.md +1 -1
  8. package/runtime/bin/okstra-antigravity-exec.sh +8 -2
  9. package/runtime/bin/okstra-claude-exec.sh +8 -2
  10. package/runtime/bin/okstra-codex-exec.sh +65 -8
  11. package/runtime/prompts/lead/adapters/claude-code.md +1 -0
  12. package/runtime/prompts/lead/convergence.md +8 -1
  13. package/runtime/prompts/lead/okstra-lead-contract.md +26 -1
  14. package/runtime/prompts/lead/report-writer.md +1 -1
  15. package/runtime/prompts/lead/team-contract.md +14 -3
  16. package/runtime/prompts/profiles/final-verification.md +10 -6
  17. package/runtime/python/okstra_ctl/analysis_packet.py +7 -2
  18. package/runtime/python/okstra_ctl/codex_dispatch.py +27 -1
  19. package/runtime/python/okstra_ctl/design_surfaces.py +30 -14
  20. package/runtime/python/okstra_ctl/dispatch_core.py +20 -0
  21. package/runtime/python/okstra_ctl/log_report.py +44 -5
  22. package/runtime/python/okstra_ctl/path_hints.py +5 -0
  23. package/runtime/python/okstra_ctl/render.py +22 -0
  24. package/runtime/python/okstra_ctl/render_final_report.py +71 -5
  25. package/runtime/python/okstra_ctl/run.py +69 -3
  26. package/runtime/python/okstra_ctl/schema_excerpt.py +17 -1
  27. package/runtime/python/okstra_ctl/wizard.py +15 -4
  28. package/runtime/python/okstra_ctl/work_categories.py +37 -0
  29. package/runtime/python/okstra_ctl/worker_heartbeat.py +50 -0
  30. package/runtime/python/okstra_ctl/worker_liveness.py +140 -0
  31. package/runtime/python/okstra_ctl/worker_prompt_contract.py +223 -0
  32. package/runtime/python/okstra_ctl/worker_prompt_headers.py +51 -2
  33. package/runtime/templates/reports/final-report.template.md +9 -9
  34. package/runtime/templates/worker-prompt-preamble.md +13 -5
  35. package/runtime/validators/forbidden_actions.py +43 -4
  36. package/runtime/validators/validate-implementation-plan-stages.py +6 -4
  37. package/runtime/validators/validate-report-views.py +4 -4
  38. package/runtime/validators/validate-run.py +61 -6
  39. package/runtime/validators/validate-schedule.py +6 -4
  40. package/runtime/validators/validate_fanout.py +4 -3
  41. package/runtime/validators/validate_improvement_report.py +6 -4
  42. package/runtime/validators/validate_session_conformance.py +16 -7
  43. package/src/cli-registry.mjs +7 -0
  44. package/src/commands/inspect/log-report.mjs +5 -3
  45. package/src/commands/inspect/worker-liveness.mjs +31 -0
  46. package/src/commands/lifecycle/preflight.mjs +25 -2
  47. package/src/lib/helper-scripts.mjs +23 -0
  48. 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
- "Required deliverable shape",
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 not in {"implementation", "final-verification"} or not worktree:
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
- def _stage_for_selected_path(
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
- normalised = _normalise(path)
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 len(matched) != 1:
214
+ if not matched:
199
215
  raise DesignSurfaceError(
200
- f"selected option path {path!r} is not mapped to a stage exactly once"
216
+ f"selected option path {path!r} is not mapped to a stage"
201
217
  )
202
- return matched[0]
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 = _stage_for_selected_path(path, stages)
230
- for kind, evidence in _matching_rule_evidence(
231
- step=None,
232
- field="files",
233
- value=path,
234
- ):
235
- grouped.setdefault((stage_number, kind), []).append(evidence)
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({"path": str(p), "sizeBytes": size, "mtimeEpoch": mtime,
72
- "taskKey": tk, **meta})
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": {"fileCount": len(files),
79
- "totalBytes": sum(f["sizeBytes"] for f in files),
80
- "taskCount": len({f["taskKey"] for f in files if f["taskKey"]})},
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
- rendered = render(
546
- data,
547
- template_path=resolved_template,
548
- report_language=report_language,
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
- ctx["VERIFICATION_TARGET"] = (
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(load_schema(), inp.task_type)
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
- "Falls back to `unknown` when omitted."
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
- def build_schema_excerpt(schema: dict, task_type: str) -> dict:
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