okstra 0.130.1 → 0.130.3

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 (43) hide show
  1. package/docs/architecture.md +7 -1
  2. package/docs/cli.md +10 -2
  3. package/docs/project-structure-overview.md +16 -9
  4. package/docs/task-process/implementation-planning.md +11 -5
  5. package/package.json +1 -1
  6. package/runtime/BUILD.json +2 -2
  7. package/runtime/agents/workers/antigravity-worker.md +3 -1
  8. package/runtime/agents/workers/claude-worker.md +1 -1
  9. package/runtime/agents/workers/codex-worker.md +3 -1
  10. package/runtime/agents/workers/report-writer-worker.md +8 -4
  11. package/runtime/prompts/lead/adapters/claude-code.md +2 -2
  12. package/runtime/prompts/lead/convergence.md +44 -21
  13. package/runtime/prompts/lead/okstra-lead-contract.md +9 -2
  14. package/runtime/prompts/lead/plan-body-verification.md +55 -12
  15. package/runtime/prompts/lead/report-writer.md +16 -15
  16. package/runtime/prompts/lead/team-contract.md +8 -6
  17. package/runtime/prompts/profiles/implementation-planning.md +10 -2
  18. package/runtime/python/okstra_ctl/codex_dispatch.py +5 -1
  19. package/runtime/python/okstra_ctl/convergence.py +121 -1
  20. package/runtime/python/okstra_ctl/convergence_engine.py +903 -13
  21. package/runtime/python/okstra_ctl/convergence_migration.py +9 -1
  22. package/runtime/python/okstra_ctl/dispatch_core.py +13 -0
  23. package/runtime/python/okstra_ctl/plan_items.py +203 -0
  24. package/runtime/python/okstra_ctl/plan_items_cli.py +81 -0
  25. package/runtime/python/okstra_ctl/report_finalize.py +6 -4
  26. package/runtime/python/okstra_ctl/worker_artifact_paths.py +23 -0
  27. package/runtime/python/okstra_ctl/worker_liveness.py +4 -4
  28. package/runtime/python/okstra_ctl/worker_prompt_body.py +1 -1
  29. package/runtime/python/okstra_ctl/worker_prompt_contract.py +2 -1
  30. package/runtime/python/okstra_ctl/worker_prompt_headers.py +14 -0
  31. package/runtime/schemas/convergence-critic-results-v1.0.schema.json +57 -0
  32. package/runtime/schemas/convergence-groups-v1.0.schema.json +109 -0
  33. package/runtime/schemas/convergence-round-results-v1.0.schema.json +55 -0
  34. package/runtime/schemas/final-report-v1.0.schema.json +21 -2
  35. package/runtime/templates/report-writer-prompt-preamble.md +5 -3
  36. package/runtime/templates/reports/final-report.template.md +1 -1
  37. package/runtime/templates/worker-prompt-preamble.md +8 -7
  38. package/runtime/validators/validate-run.py +154 -118
  39. package/runtime/validators/validate_session_conformance.py +66 -23
  40. package/src/cli-registry.mjs +7 -0
  41. package/src/commands/execute/convergence.mjs +6 -1
  42. package/src/commands/execute/plan-items.mjs +9 -0
  43. package/src/commands/inspect/worker-liveness.mjs +2 -2
@@ -10,6 +10,7 @@ from typing import Any, Literal, Mapping
10
10
 
11
11
  from .convergence_engine import (
12
12
  ConvergenceContractError,
13
+ FINAL_SCHEMA_VERSION,
13
14
  grouped_input_digest,
14
15
  validate_final_state,
15
16
  validate_working_state,
@@ -45,7 +46,14 @@ def decide_seed_action(
45
46
  digest = grouped_input_digest(grouped_input)
46
47
  final_status, final_value = _inspect_final(final_state_path)
47
48
  if final_status == "terminal":
48
- return SeedDecision("reuse-final", None, None, "valid terminal final state")
49
+ assert final_value is not None
50
+ version = final_value.get("schemaVersion")
51
+ reason = (
52
+ f"valid historical final {version} reused without rewrite"
53
+ if version != FINAL_SCHEMA_VERSION
54
+ else "valid terminal final state"
55
+ )
56
+ return SeedDecision("reuse-final", None, None, reason)
49
57
 
50
58
  work_status, work_value = _inspect_work(work_state_path)
51
59
  invalid_final = final_status == "invalid"
@@ -21,12 +21,15 @@ from .worker_prompt_headers import WorkerPromptHeaderError, worker_prompt_header
21
21
  from .worker_prompt_body import analysis_prompt_body
22
22
  from .worker_prompt_contract import PromptRecord, validate_initial_prompt_records
23
23
  from .worker_prompt_policy import resolve_prompt_plan_for_manifest
24
+ from .worker_artifact_paths import audit_sidecar_rel
24
25
  from .wrapper_status import read_wrapper_status, status_path_for_prompt
25
26
 
26
27
 
27
28
  BACKEND_CLI_WRAPPER = "cli-wrapper"
28
29
  BACKEND_TMUX_PANE = "tmux-pane"
29
30
  BACKEND_MIXED = "mixed"
31
+ LIVENESS_AUDIT_HEARTBEAT = "audit-heartbeat"
32
+ LIVENESS_WRAPPER_STATUS = "wrapper-status"
30
33
  MAX_WORKER_ATTEMPTS = 2
31
34
  REPORT_WRITER_WORKER_ID = "report-writer"
32
35
  TERMINAL_DISPATCH_STATUSES = {"completed", "timeout", "error", "not-run"}
@@ -556,6 +559,10 @@ def _dispatch_record(
556
559
  "promptPath": str(job.prompt_path),
557
560
  "resultPath": str(job.result_path),
558
561
  "workerResultPath": str(job.worker_result_path),
562
+ "auditSidecarPath": audit_sidecar_rel(str(job.worker_result_path)),
563
+ "livenessMode": _liveness_mode(
564
+ BACKEND_CLI_WRAPPER if degraded_from else job.backend
565
+ ),
559
566
  "statusSidecarPath": str(status_path_for_prompt(job.prompt_path)),
560
567
  "degradedFrom": degraded_from,
561
568
  "reason": reason,
@@ -566,6 +573,12 @@ def _dispatch_record(
566
573
  }
567
574
 
568
575
 
576
+ def _liveness_mode(backend: str) -> str:
577
+ if backend in (BACKEND_CLI_WRAPPER, BACKEND_TMUX_PANE):
578
+ return LIVENESS_WRAPPER_STATUS
579
+ return LIVENESS_AUDIT_HEARTBEAT
580
+
581
+
569
582
  def _update_dispatch_status(
570
583
  team_state_path: Path, worker_id: str, kind: str, attempt: int, status: str, reason: str
571
584
  ) -> None:
@@ -0,0 +1,203 @@
1
+ """Deterministic extraction of implementation-planning verification items."""
2
+ from __future__ import annotations
3
+
4
+ from collections.abc import Mapping
5
+ from copy import deepcopy
6
+ from typing import Any
7
+
8
+ from .design_surfaces import (
9
+ DesignSurfaceError,
10
+ detect_design_surfaces,
11
+ expected_prep_plan_item_id,
12
+ )
13
+
14
+
15
+ class PlanItemContractError(ValueError):
16
+ """The implementation-planning body cannot produce exact plan items."""
17
+
18
+
19
+ PLAN_ITEM_SOURCES = (
20
+ ("optionCandidates", "P-Opt", "4.5.1"),
21
+ ("stepwiseExecution", "P-Step", "4.5.4"),
22
+ ("dependencyMigrationRisk", "P-Dep", "4.5.5"),
23
+ ("validationChecklist", "P-Val", "4.5.6"),
24
+ ("rollbackStrategy", "P-Rb", "4.5.7"),
25
+ ("requirementCoverage", "P-Req", "4.5.8"),
26
+ )
27
+
28
+ _SUBJECT_FIELDS = {
29
+ "optionCandidates": ("subject", "name", "title", "action"),
30
+ "stepwiseExecution": ("subject", "action", "title", "name"),
31
+ "dependencyMigrationRisk": ("subject", "item", "title", "name", "action"),
32
+ "validationChecklist": ("subject", "check", "title", "name", "action"),
33
+ "rollbackStrategy": ("subject", "action", "title", "name"),
34
+ "requirementCoverage": ("subject", "requirement", "title", "name", "action"),
35
+ }
36
+
37
+
38
+ def _non_empty_string(value: object) -> str | None:
39
+ if isinstance(value, str) and value.strip():
40
+ return value
41
+ return None
42
+
43
+
44
+ def _subject(row: Mapping[str, Any], source: str) -> str:
45
+ for field in _SUBJECT_FIELDS[source]:
46
+ value = _non_empty_string(row.get(field))
47
+ if value is not None:
48
+ return value
49
+ raise PlanItemContractError(f"{source} row has no non-empty subject candidate")
50
+
51
+
52
+ def _ticket_id(row: Mapping[str, Any]) -> str:
53
+ return _non_empty_string(row.get("ticketId")) or ""
54
+
55
+
56
+ def _row_payload(row: Mapping[str, Any], **coordinates: int) -> dict[str, Any]:
57
+ payload = deepcopy(dict(row))
58
+ for field, value in coordinates.items():
59
+ existing = payload.get(field)
60
+ if field in payload and existing != value:
61
+ raise PlanItemContractError(
62
+ f"row {field} coordinate {existing!r} conflicts with {value!r}"
63
+ )
64
+ payload[field] = value
65
+ return payload
66
+
67
+
68
+ def _add_item(items: list[dict[str, Any]], item: dict[str, Any]) -> None:
69
+ if any(existing["id"] == item["id"] for existing in items):
70
+ raise PlanItemContractError(f"duplicate plan item ID: {item['id']}")
71
+ items.append(item)
72
+
73
+
74
+ def _rows(planning: Mapping[str, Any], source: str) -> list[Mapping[str, Any]]:
75
+ value = planning.get(source, [])
76
+ if value is None:
77
+ return []
78
+ if not isinstance(value, list):
79
+ raise PlanItemContractError(f"{source} must be an array")
80
+ rows: list[Mapping[str, Any]] = []
81
+ for row in value:
82
+ if not isinstance(row, Mapping):
83
+ raise PlanItemContractError(f"{source} row must be an object")
84
+ rows.append(row)
85
+ return rows
86
+
87
+
88
+ def _coordinate(value: object, field: str) -> int:
89
+ if not isinstance(value, int) or isinstance(value, bool) or value < 1:
90
+ raise PlanItemContractError(f"{field} must be a positive integer")
91
+ return value
92
+
93
+
94
+ def _extract_standard_items(planning: Mapping[str, Any]) -> list[dict[str, Any]]:
95
+ items: list[dict[str, Any]] = []
96
+ for source, prefix, section in PLAN_ITEM_SOURCES:
97
+ if source == "stepwiseExecution":
98
+ _extract_step_items(planning, items, prefix, section)
99
+ continue
100
+ for index, row in enumerate(_rows(planning, source), start=1):
101
+ _add_item(
102
+ items,
103
+ {
104
+ "id": f"{prefix}-{index}",
105
+ "subject": _subject(row, source),
106
+ "sourceSection": section,
107
+ "ticketId": _ticket_id(row),
108
+ "payload": _row_payload(row),
109
+ },
110
+ )
111
+ return items
112
+
113
+
114
+ def _extract_step_items(
115
+ planning: Mapping[str, Any],
116
+ items: list[dict[str, Any]],
117
+ prefix: str,
118
+ section: str,
119
+ ) -> None:
120
+ if "stages" not in planning:
121
+ for index, row in enumerate(_rows(planning, "stepwiseExecution"), start=1):
122
+ _add_item(
123
+ items,
124
+ {
125
+ "id": f"{prefix}-{index}",
126
+ "subject": _subject(row, "stepwiseExecution"),
127
+ "sourceSection": section,
128
+ "ticketId": _ticket_id(row),
129
+ "payload": _row_payload(row),
130
+ },
131
+ )
132
+ return
133
+ stages = planning["stages"]
134
+ if not isinstance(stages, list):
135
+ raise PlanItemContractError("stages must be an array")
136
+ for stage in stages:
137
+ if not isinstance(stage, Mapping):
138
+ raise PlanItemContractError("stages row must be an object")
139
+ stage_number = _coordinate(stage.get("stage"), "stage")
140
+ for row in _rows(stage, "stepwiseExecution"):
141
+ step_number = _coordinate(row.get("step"), "step")
142
+ _add_item(
143
+ items,
144
+ {
145
+ "id": f"{prefix}-{stage_number}.{step_number}",
146
+ "subject": _subject(row, "stepwiseExecution"),
147
+ "sourceSection": section,
148
+ "ticketId": _ticket_id(row),
149
+ "payload": _row_payload(
150
+ row,
151
+ stage=stage_number,
152
+ step=step_number,
153
+ ),
154
+ },
155
+ )
156
+
157
+
158
+ def _extract_prep_items(
159
+ planning: Mapping[str, Any], items: list[dict[str, Any]]
160
+ ) -> None:
161
+ if not planning.get("stages"):
162
+ return
163
+ try:
164
+ triggers = detect_design_surfaces(planning)
165
+ except (DesignSurfaceError, TypeError, AttributeError) as exc:
166
+ raise PlanItemContractError(f"design-surface detection failed: {exc}") from exc
167
+ for trigger in triggers:
168
+ item_id = expected_prep_plan_item_id(trigger.stage, trigger.kind)
169
+ _add_item(
170
+ items,
171
+ {
172
+ "id": item_id,
173
+ "subject": f"Stage {trigger.stage} {trigger.kind} design preparation",
174
+ "sourceSection": "5.5.10",
175
+ "ticketId": "",
176
+ "payload": {
177
+ "stage": trigger.stage,
178
+ "kind": trigger.kind,
179
+ "evidence": [
180
+ {
181
+ "step": evidence.step,
182
+ "field": evidence.field,
183
+ "match": evidence.match,
184
+ }
185
+ for evidence in trigger.evidence
186
+ ],
187
+ },
188
+ },
189
+ )
190
+
191
+
192
+ def extract_plan_items(implementation_planning: Mapping[str, Any]) -> list[dict[str, Any]]:
193
+ """Return deterministic, lossless P-* items in contract order."""
194
+ if not isinstance(implementation_planning, Mapping):
195
+ raise PlanItemContractError("implementationPlanning must be an object")
196
+ items = _extract_standard_items(implementation_planning)
197
+ _extract_prep_items(implementation_planning, items)
198
+ return items
199
+
200
+
201
+ def expected_plan_item_ids(implementation_planning: Mapping[str, Any]) -> list[str]:
202
+ """Return the exact ordered IDs produced by extract_plan_items()."""
203
+ return [item["id"] for item in extract_plan_items(implementation_planning)]
@@ -0,0 +1,81 @@
1
+ """CLI adapter for deterministic implementation-planning item extraction."""
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import json
6
+ from collections.abc import Mapping
7
+ from pathlib import Path
8
+ import sys
9
+ from typing import Any
10
+
11
+ from .convergence_store import write_json_atomic
12
+ from .plan_items import PlanItemContractError, extract_plan_items
13
+
14
+
15
+ def _load_json_object(path: Path) -> dict[str, Any]:
16
+ try:
17
+ value = json.loads(path.read_text(encoding="utf-8"))
18
+ except (OSError, UnicodeError, json.JSONDecodeError) as exc:
19
+ raise PlanItemContractError(f"cannot read JSON object {path}: {exc}") from exc
20
+ if not isinstance(value, dict):
21
+ raise PlanItemContractError(f"JSON input must be an object: {path}")
22
+ return value
23
+
24
+
25
+ def _planning(data: Mapping[str, Any]) -> Mapping[str, Any]:
26
+ planning = data.get("implementationPlanning")
27
+ if not isinstance(planning, Mapping):
28
+ raise PlanItemContractError("implementationPlanning must be an object")
29
+ return planning
30
+
31
+
32
+ def _envelope(data: Mapping[str, Any]) -> dict[str, Any]:
33
+ return {
34
+ "schemaVersion": "1.0",
35
+ "taskType": "implementation-planning",
36
+ "items": extract_plan_items(_planning(data)),
37
+ }
38
+
39
+
40
+ def _parser() -> argparse.ArgumentParser:
41
+ parser = argparse.ArgumentParser(
42
+ prog="okstra plan-items",
43
+ description="Extract and validate deterministic plan-body items.",
44
+ )
45
+ commands = parser.add_subparsers(dest="command", required=True)
46
+ extract = commands.add_parser("extract")
47
+ extract.add_argument("--data", type=Path, required=True)
48
+ extract.add_argument("--output", type=Path, required=True)
49
+ validate = commands.add_parser("validate")
50
+ validate.add_argument("--data", type=Path, required=True)
51
+ validate.add_argument("--items", type=Path, required=True)
52
+ return parser
53
+
54
+
55
+ def _extract(args: argparse.Namespace) -> dict[str, Any]:
56
+ envelope = _envelope(_load_json_object(args.data))
57
+ write_json_atomic(args.output, envelope)
58
+ return {"ok": True, "operation": "extract", "path": str(args.output)}
59
+
60
+
61
+ def _validate(args: argparse.Namespace) -> dict[str, Any]:
62
+ expected = _envelope(_load_json_object(args.data))
63
+ actual = _load_json_object(args.items)
64
+ if actual != expected:
65
+ raise PlanItemContractError("plan items envelope does not match deterministic extraction")
66
+ return {"ok": True, "operation": "validate", "path": str(args.items)}
67
+
68
+
69
+ def main(argv: list[str] | None = None) -> int:
70
+ args = _parser().parse_args(argv)
71
+ try:
72
+ result = _extract(args) if args.command == "extract" else _validate(args)
73
+ except (PlanItemContractError, OSError, ValueError) as exc:
74
+ print(f"plan-items: {exc}", file=sys.stderr)
75
+ return 2
76
+ print(json.dumps(result, ensure_ascii=False, indent=2))
77
+ return 0
78
+
79
+
80
+ if __name__ == "__main__":
81
+ raise SystemExit(main())
@@ -121,19 +121,21 @@ def task_manifest_path(project_root: Path, manifest: Mapping[str, Any]) -> Path:
121
121
 
122
122
 
123
123
  def resolve_workspace_script(workspace_root: Path, script_name: str) -> Path:
124
- return _resolve_workspace_file(workspace_root, "scripts", script_name)
124
+ # The build lands helper scripts under `bin/`; `scripts/` is the repo layout.
125
+ return _resolve_workspace_file(workspace_root, ("bin", "scripts"), script_name)
125
126
 
126
127
 
127
128
  def resolve_workspace_validator(workspace_root: Path, validator_name: str) -> Path:
128
- return _resolve_workspace_file(workspace_root, "validators", validator_name)
129
+ return _resolve_workspace_file(workspace_root, ("validators",), validator_name)
129
130
 
130
131
 
131
132
  def _resolve_workspace_file(
132
133
  workspace_root: Path,
133
- subdir: str,
134
+ subdirs: tuple[str, ...],
134
135
  name: str,
135
136
  ) -> Path:
136
- candidates = [workspace_root / subdir / name, workspace_root / name]
137
+ candidates = [workspace_root / subdir / name for subdir in subdirs]
138
+ candidates.append(workspace_root / name)
137
139
  for candidate in candidates:
138
140
  if candidate.is_file():
139
141
  return candidate.resolve()
@@ -0,0 +1,23 @@
1
+ """Canonical worker artifact path derivation."""
2
+ from __future__ import annotations
3
+
4
+
5
+ class WorkerArtifactPathError(ValueError):
6
+ """Raised when a worker artifact path is not canonical."""
7
+
8
+
9
+ def audit_sidecar_rel(worker_result_rel: str) -> str:
10
+ """Insert `-audit-` after the first canonical `-worker-` token."""
11
+ if not worker_result_rel.endswith(".md"):
12
+ raise WorkerArtifactPathError(
13
+ f"worker result path must end with .md: {worker_result_rel}"
14
+ )
15
+ directory, slash, filename = worker_result_rel.rpartition("/")
16
+ basename = filename if slash else worker_result_rel
17
+ prefix, separator, suffix = basename.partition("-worker-")
18
+ if not separator:
19
+ raise WorkerArtifactPathError(
20
+ f"worker result path has no canonical -worker- token: {worker_result_rel}"
21
+ )
22
+ audit_filename = f"{prefix}-worker-audit-{suffix}"
23
+ return f"{directory}{slash}{audit_filename}" if slash else audit_filename
@@ -12,9 +12,9 @@ verdict and spends its existing one-retry budget.
12
12
 
13
13
  Two probes, matching the two ways a pending worker goes quiet:
14
14
 
15
- * ``--audit`` — a `claude-worker` audit sidecar. Stale past the heartbeat
16
- cadence (or present with no heartbeat at all) means the in-process worker
17
- hung. Uses the same line shape and budget the Phase 7 validator applies.
15
+ * ``--audit`` — an in-process worker audit sidecar. Stale past the heartbeat
16
+ cadence (or present with no heartbeat at all) means the worker hung. Uses
17
+ the same line shape and budget the Phase 7 validator applies.
18
18
  * ``--prompt`` — a CLI-wrapper prompt-history path. Neither `<prompt>.log` nor
19
19
  `<prompt>.status.json` past the launch grace means the wrapper never ran —
20
20
  the dispatch itself failed, which no artifact otherwise distinguishes from a
@@ -110,7 +110,7 @@ def main(argv: list[str] | None = None) -> int:
110
110
  description="Report whether pending workers are still alive (read-only).",
111
111
  )
112
112
  parser.add_argument("--audit", action="append", default=[],
113
- help="in-process claude-worker audit sidecar path (repeatable)")
113
+ help="in-process worker audit sidecar path (repeatable)")
114
114
  parser.add_argument("--prompt", action="append", default=[],
115
115
  help="CLI-wrapper (codex/antigravity) prompt-history path "
116
116
  "(repeatable). Only the wrappers write the artifacts "
@@ -47,7 +47,7 @@ def analysis_prompt_body(
47
47
  "## Output Contract",
48
48
  "- Read the Worker Preamble Path end-to-end before analysis.",
49
49
  "- Write the worker result to Result Path and no other canonical result path.",
50
- "- Write the audit sidecar and any tool-failure entries as the preamble requires.",
50
+ "- Write the audit sidecar to Audit sidecar path as the preamble requires.",
51
51
  "- Cite evidence with file paths and line numbers whenever you make a claim.",
52
52
  ]
53
53
 
@@ -32,6 +32,7 @@ _NON_BODY_PREFIXES = (
32
32
  "**Project Root:**",
33
33
  "**Prompt History Path:**",
34
34
  "**Result Path:**",
35
+ "**Audit sidecar path:**",
35
36
  "Assigned worker prompt history path:",
36
37
  "**Worker Preamble Path:**",
37
38
  "**Errors log path:**",
@@ -55,6 +56,7 @@ _REQUIRED_TARGET_PREFIXES = (
55
56
  _WORKER_SPECIFIC_PREFIXES = (
56
57
  "**Prompt History Path:**",
57
58
  "**Result Path:**",
59
+ "**Audit sidecar path:**",
58
60
  "Assigned worker prompt history path:",
59
61
  "**Errors sidecar path:**",
60
62
  "**Worker Result Path:**",
@@ -135,7 +137,6 @@ def normalise_analysis_prompt(text: str) -> str:
135
137
  "",
136
138
  )
137
139
  if prefix:
138
- normalized.append(f"{prefix} <worker-specific>")
139
140
  continue
140
141
  normalized.append(_WORKER_LABEL_RE.sub("<analysis-worker>", line.rstrip()))
141
142
  return "\n".join(normalized).strip() + "\n"
@@ -5,6 +5,7 @@ from pathlib import Path
5
5
  from typing import Any, Mapping
6
6
 
7
7
  from .paths import okstra_home
8
+ from .worker_artifact_paths import WorkerArtifactPathError, audit_sidecar_rel
8
9
  from .worker_prompt_policy import (
9
10
  GRILLING_LOG_HEADER,
10
11
  PromptPlan,
@@ -42,6 +43,7 @@ def worker_prompt_headers(
42
43
  project_root: Path,
43
44
  prompt_rel: str,
44
45
  result_rel: str,
46
+ audit_source_rel: str | None = None,
45
47
  worker_id: str,
46
48
  dispatch_kind: str,
47
49
  manifest: Mapping[str, Any],
@@ -49,6 +51,10 @@ def worker_prompt_headers(
49
51
  ) -> list[str]:
50
52
  """Render the team-contract worker prompt anchor headers."""
51
53
  prompt_path = _resolve_project_path(project_root, prompt_rel)
54
+ audit_sidecar_path = _audit_sidecar_path(
55
+ project_root,
56
+ audit_source_rel or result_rel,
57
+ )
52
58
  errors_log_path = _errors_log_path(project_root, manifest, active_context)
53
59
  errors_sidecar_path = _worker_errors_sidecar_path(
54
60
  project_root,
@@ -61,6 +67,7 @@ def worker_prompt_headers(
61
67
  f"**Project Root:** {project_root}",
62
68
  f"**Prompt History Path:** {prompt_rel}",
63
69
  f"**Result Path:** {result_rel}",
70
+ f"**Audit sidecar path:** {audit_sidecar_path}",
64
71
  f"Assigned worker prompt history path: {prompt_path}",
65
72
  f"**Worker Preamble Path:** {_worker_preamble_path(plan, active_context)}",
66
73
  f"**Worker Error Contract Path:** {_worker_error_contract_path(active_context)}",
@@ -235,6 +242,13 @@ def _resolve_project_path(project_root: Path, value: str) -> Path:
235
242
  return path if path.is_absolute() else project_root / path
236
243
 
237
244
 
245
+ def _audit_sidecar_path(project_root: Path, worker_result_rel: str) -> Path:
246
+ try:
247
+ return _resolve_project_path(project_root, audit_sidecar_rel(worker_result_rel))
248
+ except WorkerArtifactPathError as exc:
249
+ raise WorkerPromptHeaderError(str(exc)) from exc
250
+
251
+
238
252
  def _require_string(payload: Mapping[str, Any], key: str) -> str:
239
253
  value = payload.get(key)
240
254
  if not isinstance(value, str) or not value.strip():
@@ -0,0 +1,57 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://okstra.dev/schemas/convergence-critic-results-v1.0.json",
4
+ "title": "OKSTRA Convergence Critic Results (v1.0)",
5
+ "type": "object",
6
+ "required": ["schemaVersion", "taskKey", "mode", "provider", "terminalStatus", "durationMs", "candidates"],
7
+ "additionalProperties": false,
8
+ "properties": {
9
+ "schemaVersion": { "const": "1.0" },
10
+ "taskKey": { "type": "string", "pattern": "\\S" },
11
+ "mode": { "enum": ["coverage", "acceptance-devils-advocate"] },
12
+ "provider": { "type": "string", "pattern": "\\S" },
13
+ "terminalStatus": { "enum": ["completed", "timeout", "error", "not-run"] },
14
+ "durationMs": { "type": "integer", "minimum": 0 },
15
+ "candidates": {
16
+ "type": "array",
17
+ "items": { "$ref": "#/$defs/Candidate" }
18
+ }
19
+ },
20
+ "$defs": {
21
+ "Candidate": {
22
+ "type": "object",
23
+ "required": ["candidateId", "summary", "category", "ticketIds", "originEvidence"],
24
+ "additionalProperties": false,
25
+ "properties": {
26
+ "candidateId": { "type": "string", "pattern": "\\S" },
27
+ "summary": { "type": "string", "pattern": "\\S" },
28
+ "category": { "type": "string", "pattern": "\\S" },
29
+ "ticketIds": {
30
+ "type": "array",
31
+ "items": { "type": "string", "pattern": "\\S" }
32
+ },
33
+ "originEvidence": { "type": "string", "pattern": "\\S" },
34
+ "severity": { "enum": ["critical", "major", "minor"] },
35
+ "evidenceArtifacts": {
36
+ "type": "array",
37
+ "minItems": 1,
38
+ "items": { "$ref": "#/$defs/EvidenceArtifact" }
39
+ }
40
+ }
41
+ },
42
+ "EvidenceArtifact": {
43
+ "type": "object",
44
+ "required": ["path", "sha256", "command", "environment"],
45
+ "additionalProperties": false,
46
+ "properties": {
47
+ "path": {
48
+ "type": "string",
49
+ "pattern": "^\\.okstra/(?!\\.{1,2}(?:/|$))(?!.*?/\\.{1,2}(?:/|$))[^/\\\\\\r\\n]+(?:/[^/\\\\\\r\\n]+)*$"
50
+ },
51
+ "sha256": { "type": "string", "pattern": "^[0-9a-f]{64}$" },
52
+ "command": { "type": "string", "pattern": "\\S" },
53
+ "environment": { "type": "string", "pattern": "\\S" }
54
+ }
55
+ }
56
+ }
57
+ }
@@ -0,0 +1,109 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://okstra.dev/schemas/convergence-groups-v1.0.json",
4
+ "title": "OKSTRA Convergence Grouped Input (v1.0)",
5
+ "type": "object",
6
+ "required": ["schemaVersion", "taskKey", "config", "workers", "groups"],
7
+ "additionalProperties": false,
8
+ "properties": {
9
+ "schemaVersion": { "const": "1.0" },
10
+ "taskKey": { "type": "string", "pattern": "\\S" },
11
+ "config": { "$ref": "#/$defs/Config" },
12
+ "workers": {
13
+ "type": "array",
14
+ "minItems": 1,
15
+ "items": { "$ref": "#/$defs/Worker" }
16
+ },
17
+ "groups": {
18
+ "type": "array",
19
+ "items": { "$ref": "#/$defs/Group" }
20
+ }
21
+ },
22
+ "$defs": {
23
+ "Config": {
24
+ "type": "object",
25
+ "required": ["enabled", "adversarial", "maxRounds", "effectiveMaxRounds", "verificationMode"],
26
+ "additionalProperties": false,
27
+ "properties": {
28
+ "enabled": { "type": "boolean" },
29
+ "adversarial": { "type": "boolean" },
30
+ "maxRounds": { "type": "integer", "minimum": 1, "maximum": 3 },
31
+ "effectiveMaxRounds": { "type": "integer", "minimum": 1, "maximum": 3 },
32
+ "verificationMode": { "enum": ["lightweight", "full-reanalysis"] }
33
+ }
34
+ },
35
+ "Worker": {
36
+ "type": "object",
37
+ "required": ["workerId", "audience"],
38
+ "additionalProperties": false,
39
+ "properties": {
40
+ "workerId": { "type": "string", "pattern": "\\S" },
41
+ "audience": { "enum": ["analysis", "report-writer"] }
42
+ }
43
+ },
44
+ "Group": {
45
+ "type": "object",
46
+ "required": ["findingId", "summary", "category", "ticketIds", "originWorker", "originEvidence", "discoveredBy", "sourceItems"],
47
+ "additionalProperties": false,
48
+ "properties": {
49
+ "findingId": { "type": "string", "pattern": "\\S" },
50
+ "summary": { "type": "string", "pattern": "\\S" },
51
+ "category": { "type": "string", "pattern": "\\S" },
52
+ "ticketIds": {
53
+ "type": "array",
54
+ "minItems": 1,
55
+ "items": { "type": "string", "pattern": "\\S" }
56
+ },
57
+ "originWorker": { "type": "string", "pattern": "\\S" },
58
+ "originEvidence": { "type": "string", "pattern": "\\S" },
59
+ "evidenceArtifacts": {
60
+ "type": "array",
61
+ "minItems": 1,
62
+ "items": { "$ref": "#/$defs/EvidenceArtifact" }
63
+ },
64
+ "discoveredBy": {
65
+ "type": "object",
66
+ "minProperties": 1,
67
+ "additionalProperties": { "$ref": "#/$defs/Discovery" }
68
+ },
69
+ "sourceItems": {
70
+ "type": "array",
71
+ "minItems": 1,
72
+ "items": { "$ref": "#/$defs/SourceItem" }
73
+ }
74
+ }
75
+ },
76
+ "EvidenceArtifact": {
77
+ "type": "object",
78
+ "required": ["path", "sha256", "command", "environment"],
79
+ "additionalProperties": false,
80
+ "properties": {
81
+ "path": {
82
+ "type": "string",
83
+ "pattern": "^\\.okstra/(?!\\.{1,2}(?:/|$))(?!.*?/\\.{1,2}(?:/|$))[^/\\\\\\r\\n]+(?:/[^/\\\\\\r\\n]+)*$"
84
+ },
85
+ "sha256": { "type": "string", "pattern": "^[0-9a-f]{64}$" },
86
+ "command": { "type": "string", "pattern": "\\S" },
87
+ "environment": { "type": "string", "pattern": "\\S" }
88
+ }
89
+ },
90
+ "Discovery": {
91
+ "type": "object",
92
+ "required": ["itemId", "evidence"],
93
+ "additionalProperties": false,
94
+ "properties": {
95
+ "itemId": { "type": "string", "pattern": "\\S" },
96
+ "evidence": { "type": "string", "pattern": "\\S" }
97
+ }
98
+ },
99
+ "SourceItem": {
100
+ "type": "object",
101
+ "required": ["worker", "itemId"],
102
+ "additionalProperties": false,
103
+ "properties": {
104
+ "worker": { "type": "string", "pattern": "\\S" },
105
+ "itemId": { "type": "string", "pattern": "\\S" }
106
+ }
107
+ }
108
+ }
109
+ }