okstra 0.130.2 → 0.130.4

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 (44) 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 +15 -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_views.py +32 -5
  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 +23 -3
  35. package/runtime/templates/report-writer-prompt-preamble.md +5 -3
  36. package/runtime/templates/reports/final-report.template.md +4 -4
  37. package/runtime/templates/reports/report.css +18 -0
  38. package/runtime/templates/worker-prompt-preamble.md +8 -7
  39. package/runtime/validators/validate-run.py +154 -118
  40. package/runtime/validators/validate_session_conformance.py +66 -23
  41. package/src/cli-registry.mjs +7 -0
  42. package/src/commands/execute/convergence.mjs +6 -1
  43. package/src/commands/execute/plan-items.mjs +9 -0
  44. 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())
@@ -513,6 +513,7 @@ def _emit_table(lines: list[str], start: int, section_path: list[str]) -> tuple[
513
513
  )
514
514
 
515
515
  narrow_cols = _narrow_columns(header_cells, rows)
516
+ path_cols = _path_columns(header_cells)
516
517
  # `User input` carries the embedded form widget (textarea / select /
517
518
  # input) which needs all the horizontal space it can get; its
518
519
  # markdown plain text is empty or a short placeholder so the auto
@@ -523,7 +524,7 @@ def _emit_table(lines: list[str], start: int, section_path: list[str]) -> tuple[
523
524
  head = (
524
525
  "<thead><tr>"
525
526
  + "".join(
526
- f"<th{_col_class(idx, narrow_cols)}>{_inline(c)}</th>"
527
+ f"<th{_col_class(idx, narrow_cols, path_cols)}>{_inline(c)}</th>"
527
528
  for idx, c in enumerate(header_cells)
528
529
  )
529
530
  + "</tr></thead>"
@@ -562,7 +563,7 @@ def _emit_table(lines: list[str], start: int, section_path: list[str]) -> tuple[
562
563
  )
563
564
  else:
564
565
  cells_html.append(
565
- f"<td{_col_class(idx, narrow_cols)}>{_inline(cell)}</td>"
566
+ f"<td{_col_class(idx, narrow_cols, path_cols)}>{_inline(cell)}</td>"
566
567
  )
567
568
  body_rows.append(
568
569
  f'<tr id="{html.escape(meta.row_id.lower())}" '
@@ -579,7 +580,7 @@ def _emit_table(lines: list[str], start: int, section_path: list[str]) -> tuple[
579
580
  body_rows.append(
580
581
  tr_open
581
582
  + "".join(
582
- f"<td{_col_class(idx, narrow_cols)}>{_inline(c)}</td>"
583
+ f"<td{_col_class(idx, narrow_cols, path_cols)}>{_inline(c)}</td>"
583
584
  for idx, c in enumerate(row)
584
585
  )
585
586
  + "</tr>"
@@ -921,8 +922,30 @@ def _narrow_columns(
921
922
  return narrow
922
923
 
923
924
 
924
- def _col_class(col_idx: int, narrow_cols: set[int]) -> str:
925
- return ' class="td-narrow"' if col_idx in narrow_cols else ""
925
+ def _col_class(col_idx: int, narrow_cols: set[int], path_cols: set[int]) -> str:
926
+ if col_idx in narrow_cols:
927
+ return ' class="td-narrow"'
928
+ if col_idx in path_cols:
929
+ return ' class="td-path"'
930
+ return ""
931
+
932
+
933
+ # A repo-relative path has no spaces, so `overflow-wrap: anywhere` puts its
934
+ # min-content width at one character and the auto table layout hands the
935
+ # column almost nothing while a prose neighbour takes the rest — the path
936
+ # then renders one or two characters per line. `td-path` floors the width.
937
+ _PATH_HEADER_PREFIXES: tuple[str, ...] = ("path", "파일 경로", "경로")
938
+
939
+
940
+ def _path_columns(header_cells: list[str]) -> set[int]:
941
+ return {
942
+ col
943
+ for col, header in enumerate(header_cells)
944
+ if _INLINE_MD_STRIP_RE.sub("", header or "")
945
+ .strip()
946
+ .lower()
947
+ .startswith(_PATH_HEADER_PREFIXES)
948
+ }
926
949
 
927
950
 
928
951
  _DANGEROUS_URL_SCHEME_RE = re.compile(
@@ -960,6 +983,10 @@ def _inline(text: str) -> str:
960
983
  # markdown source intentionally stacks short fields with <br>). html.escape
961
984
  # above turned them into &lt;br&gt;; restore the tag.
962
985
  out = out.replace("&lt;br&gt;", "<br>").replace("&lt;br/&gt;", "<br>").replace("&lt;br /&gt;", "<br>")
986
+ # `<small>` demotes a cell's technical detail line below its plain-language
987
+ # first line (File Structure `details`). Allowlisted alongside <br>; every
988
+ # other tag stays escaped.
989
+ out = out.replace("&lt;small&gt;", "<small>").replace("&lt;/small&gt;", "</small>")
963
990
  return out
964
991
 
965
992
 
@@ -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
+ }