okstra 0.177.1 → 0.178.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/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/prompts/lead/okstra-lead-contract.md +1 -1
- package/runtime/prompts/lead/report-writer.md +5 -1
- package/runtime/python/okstra_ctl/report_views.py +35 -0
- package/runtime/python/okstra_ctl/user_response.py +20 -4
- package/runtime/schemas/final-report-v2.0.schema.json +21 -0
- package/runtime/templates/reports/final-report-v2.template.md +2 -1
- package/runtime/validators/validate-run.py +133 -0
package/package.json
CHANGED
package/runtime/BUILD.json
CHANGED
|
@@ -47,7 +47,7 @@ Read-side inspection (`/okstra-inspect`) and scheduling (`/okstra-schedule-gen`)
|
|
|
47
47
|
- The `leader` owns orchestration, convergence supervision, and final-report review/approval. It does not author the final-report file when `Report writer worker` is in the roster. `lead` is a compatibility alias for `leader` and must not be written on new artifacts.
|
|
48
48
|
- Dispatch consumes stored role executions, not provider-named worker IDs. Canonical roles are `leader`, `analyser`, `critic`, `designer`, `planner`, `implementer`, `verifier`, `report-writer`, and `translator`. `executor` is a compatibility alias for `implementer`.
|
|
49
49
|
- Pane titles and operational rows use the stored `executionLabel`. Do not rebuild that label from a provider name or model string.
|
|
50
|
-
- `report-writer`, when in the roster, is the **author** of the final-report file. Lead reviews the draft and may request a revision via a follow-up dispatch, but MUST NOT write the report itself as a "shortcut". The only legal lead-authored fallback
|
|
50
|
+
- `report-writer`, when in the roster, is the **author** of the final-report file. Lead reviews the draft and may request a revision via a follow-up dispatch, but MUST NOT write the report itself as a "shortcut". The only legal lead-authored fallback needs two things together: a Report writer worker dispatch that was actually attempted and recorded a terminal status of `error`/`timeout`/`not-run` with an explicit reason in team-state, **and** the user's permission recorded as a `## REPORT AUTHORING` block in this run's `user-responses/` sidecar. The failure is what lets you ask; only the user can answer. Record both in `header.leadAuthoredFallback` so the report itself carries the fact — see [report-writer](./report-writer.md) "Lead-authored fallback".
|
|
51
51
|
- "Session resume", "team is no longer alive", and similar are NOT valid reasons to skip Report writer worker dispatch — see [report-writer](./report-writer.md) "Resume-safe dispatch".
|
|
52
52
|
- A shell command the lead runs must not be able to ask a question. The lead's shell is the user's own, where `cp`, `mv`, and `rm` are commonly aliased to their `-i` form; the confirmation that alias raises has nobody to answer it, so the call hangs until it is killed — observed as a `cp` over an existing state file stalling a whole self-fix round. Invoke these as `command cp` / `command mv` / `command rm`, which skips alias expansion and leaves the tool's own behaviour untouched. `-f` is not a substitute: it changes what the tool does on failure (`rm -f` reports success on a path that never existed).
|
|
53
53
|
- If the brief is incomplete, continue with explicit uncertainty markers rather than fabricating confidence.
|
|
@@ -81,9 +81,13 @@ Except for `release-handoff` (which is single-lead by design and never dispatche
|
|
|
81
81
|
1. A Report writer worker dispatch was actually attempted through `dispatch_worker`.
|
|
82
82
|
2. The attempt recorded a terminal status of `error`, `timeout`, or `not-run` with a concrete reason (tool error message, timeout duration, or external blocker).
|
|
83
83
|
3. The reason is logged via `okstra error-log append-observed --error-type cli-failure ...` (or `tool-failure` if the failure was internal).
|
|
84
|
+
4. **The user permitted it.** Ask at a gate, and have the answer written to this run's `user-responses/` sidecar as a `## REPORT AUTHORING` block with `- Status: approved` (`okstra user-response write --report <data.json> --answers '[]' --report-authoring '{"status":"approved","reason":"<the user's words>"}'`). A failed dispatch is your cue to ask, not your permission — a lead that dispatches once, watches it fail, and proceeds has still decided alone. You cannot write this block for yourself; it is the user's answer, in the user's file.
|
|
85
|
+
5. **Record it in the report.** Set `header.leadAuthoredFallback` with `dispatchFailureReason` quoted verbatim from the failed dispatch row and `approvalSidecar` set to that sidecar's project-relative path. The approval passes the gate; it does not retire it. This block renders under `Report Author`, so someone reading the report months later sees that this run left the report-writer path and why, without having to find the sidecar.
|
|
84
86
|
|
|
85
87
|
Speculative reasons such as "session resume constraint", "runtime state is unavailable", or "lead can do it faster" are NOT valid.
|
|
86
88
|
|
|
89
|
+
**Enforced:** `validators/validate-run.py` `_validate_lead_authored_report` checks all of it — the terminal failure status, its reason, the approving sidecar block, and the `header.leadAuthoredFallback` record whose reason must match the dispatch row. `release-handoff` is exempt because it never dispatches a report writer.
|
|
90
|
+
|
|
87
91
|
|
|
88
92
|
## Phase 6 → Phase 7 execution sequence (BLOCKING order)
|
|
89
93
|
|
|
@@ -185,7 +189,7 @@ Read the exact permitted values for `header.reportOwner` and `header.reportAutho
|
|
|
185
189
|
- Report Owner: `<exact schema header.reportOwner compatibility value>`
|
|
186
190
|
- Report Author: `<exact schema header.reportAuthor compatibility value for the actual authorship path>`
|
|
187
191
|
- Lead model: `<lead-model>`
|
|
188
|
-
- Preparation Method: Final report authored by Report writer worker (or lead-authored fallback — record the documented dispatch failure reason here when applicable)
|
|
192
|
+
- Preparation Method: Final report authored by Report writer worker (or lead-authored fallback — record the documented dispatch failure reason here when applicable; the machine-readable record is `header.leadAuthoredFallback`, which is what the validator and the rendered report both read)
|
|
189
193
|
```
|
|
190
194
|
|
|
191
195
|
### Agent-Specific Execution Status Table
|
|
@@ -1123,6 +1123,19 @@ _ANALYSIS_REVIEW_STATUSES = frozenset({
|
|
|
1123
1123
|
})
|
|
1124
1124
|
|
|
1125
1125
|
PLAN_DECISION_APPROVED = "approved"
|
|
1126
|
+
@dataclass(frozen=True)
|
|
1127
|
+
class UserReportAuthoring:
|
|
1128
|
+
"""사용자가 리드의 최종 리포트 직접 저작을 허가했는지에 대한 답.
|
|
1129
|
+
|
|
1130
|
+
승인이어도 ``reason`` 이 필수다 — 리포트 헤더가 이 사유를 그대로 실어
|
|
1131
|
+
나중에 읽는 사람이 이 run 이 왜 report-writer 경로를 벗어났는지 본다."""
|
|
1132
|
+
status: str
|
|
1133
|
+
reason: str = ""
|
|
1134
|
+
|
|
1135
|
+
|
|
1136
|
+
REPORT_AUTHORING_APPROVED = "approved"
|
|
1137
|
+
_REPORT_AUTHORING_STATUSES = frozenset({REPORT_AUTHORING_APPROVED, "denied"})
|
|
1138
|
+
|
|
1126
1139
|
_PLAN_DECISION_STATUSES = frozenset({
|
|
1127
1140
|
PLAN_DECISION_APPROVED,
|
|
1128
1141
|
"revision-requested",
|
|
@@ -1208,6 +1221,25 @@ def _serialize_plan_decision(decision: UserPlanDecision) -> str:
|
|
|
1208
1221
|
return chunk
|
|
1209
1222
|
|
|
1210
1223
|
|
|
1224
|
+
def _serialize_report_authoring(decision: UserReportAuthoring) -> str:
|
|
1225
|
+
"""The user's answer on letting the lead author the final report itself.
|
|
1226
|
+
|
|
1227
|
+
Same shape as PLAN DECISION because it is the same kind of fact: a decision
|
|
1228
|
+
only the user may make, written into the sidecar the user owns. A reason is
|
|
1229
|
+
required even on approval — the report carries it forward, so a later reader
|
|
1230
|
+
sees why this run left the report-writer path.
|
|
1231
|
+
"""
|
|
1232
|
+
if decision.status not in _REPORT_AUTHORING_STATUSES:
|
|
1233
|
+
raise ValueError(f"invalid REPORT AUTHORING status: {decision.status}")
|
|
1234
|
+
if not decision.reason.strip():
|
|
1235
|
+
raise ValueError("REPORT AUTHORING requires a Reason")
|
|
1236
|
+
return (
|
|
1237
|
+
"\n## REPORT AUTHORING\n"
|
|
1238
|
+
f"- Status: {decision.status}\n"
|
|
1239
|
+
f"{_quoted_sidecar_field('Reason', decision.reason)}"
|
|
1240
|
+
)
|
|
1241
|
+
|
|
1242
|
+
|
|
1211
1243
|
def _serialize_direction_selection(selection: UserDirectionSelection) -> str:
|
|
1212
1244
|
option_id, option_name = normalize_direction_selection_identity(
|
|
1213
1245
|
selection.option_id, selection.option_name
|
|
@@ -1233,6 +1265,7 @@ def serialize_user_response(
|
|
|
1233
1265
|
plan_decision: UserPlanDecision | None = None,
|
|
1234
1266
|
analysis_review: UserResponseAnalysisReview | None = None,
|
|
1235
1267
|
direction_selection: UserDirectionSelection | None = None,
|
|
1268
|
+
report_authoring: UserReportAuthoring | None = None,
|
|
1236
1269
|
) -> str:
|
|
1237
1270
|
"""Return the canonical markdown text the HTML 'Export user
|
|
1238
1271
|
response' button must produce. Used by validators to confirm that
|
|
@@ -1285,6 +1318,8 @@ def serialize_user_response(
|
|
|
1285
1318
|
body_chunks.append(_serialize_analysis_review(analysis_review))
|
|
1286
1319
|
if direction_selection is not None:
|
|
1287
1320
|
body_chunks.append(_serialize_direction_selection(direction_selection))
|
|
1321
|
+
if report_authoring is not None:
|
|
1322
|
+
body_chunks.append(_serialize_report_authoring(report_authoring))
|
|
1288
1323
|
return head + "".join(body_chunks)
|
|
1289
1324
|
|
|
1290
1325
|
|
|
@@ -21,7 +21,8 @@ from typing import Optional
|
|
|
21
21
|
from okstra_ctl.report_views import (
|
|
22
22
|
PLAN_DECISION_APPROVED,
|
|
23
23
|
normalize_direction_selection_identity,
|
|
24
|
-
serialize_user_response, UserResponseEntry, UserPlanDecision,
|
|
24
|
+
serialize_user_response, UserResponseEntry, UserPlanDecision,
|
|
25
|
+
UserReportAuthoring, infer_run_meta,
|
|
25
26
|
parse_expected_form_options,
|
|
26
27
|
)
|
|
27
28
|
from okstra_ctl.report_view_artifacts import user_responses_dir_for_report
|
|
@@ -669,7 +670,8 @@ def show_open_rows(report_path: Path) -> dict:
|
|
|
669
670
|
|
|
670
671
|
def write_sidecar(report_path: Path, answers: list[dict],
|
|
671
672
|
plan_decision: Optional[dict], created_at: str,
|
|
672
|
-
task_key: str = ""
|
|
673
|
+
task_key: str = "",
|
|
674
|
+
report_authoring: Optional[dict] = None) -> Path:
|
|
673
675
|
run_meta = infer_run_meta(report_path, task_key=task_key or None)
|
|
674
676
|
out_dir = user_responses_dir_for_report(report_path)
|
|
675
677
|
out_dir.mkdir(parents=True, exist_ok=True)
|
|
@@ -690,9 +692,15 @@ def write_sidecar(report_path: Path, answers: list[dict],
|
|
|
690
692
|
status=plan_decision["status"],
|
|
691
693
|
implementation_option=plan_decision.get("implementationOption", ""),
|
|
692
694
|
reason=plan_decision.get("reason", ""))
|
|
695
|
+
authoring = None
|
|
696
|
+
if report_authoring and report_authoring.get("status"):
|
|
697
|
+
authoring = UserReportAuthoring(
|
|
698
|
+
status=report_authoring["status"],
|
|
699
|
+
reason=report_authoring.get("reason", ""))
|
|
693
700
|
sidecar.write_text(
|
|
694
701
|
serialize_user_response(run_meta=run_meta, entries=list(merged.values()),
|
|
695
|
-
created_at=created_at, plan_decision=decision
|
|
702
|
+
created_at=created_at, plan_decision=decision,
|
|
703
|
+
report_authoring=authoring),
|
|
696
704
|
encoding="utf-8")
|
|
697
705
|
return sidecar
|
|
698
706
|
|
|
@@ -715,6 +723,11 @@ def main(argv: Optional[list[str]] = None) -> int:
|
|
|
715
723
|
pw.add_argument(
|
|
716
724
|
"--plan-decision", default="",
|
|
717
725
|
help='JSON plan decision, e.g. {"status":"rejected","reason":"..."}')
|
|
726
|
+
pw.add_argument(
|
|
727
|
+
"--report-authoring", default="",
|
|
728
|
+
help='JSON report-authoring permission, e.g. '
|
|
729
|
+
'{"status":"approved","reason":"report-writer failed twice"}. '
|
|
730
|
+
'Only the user may grant this; the lead cannot write it for itself.')
|
|
718
731
|
pw.add_argument("--task-key", default="", help="task-key from list/show context")
|
|
719
732
|
|
|
720
733
|
ns = parser.parse_args(argv)
|
|
@@ -728,9 +741,12 @@ def main(argv: Optional[list[str]] = None) -> int:
|
|
|
728
741
|
if ns.cmd == "write":
|
|
729
742
|
answers = json.loads(ns.answers)
|
|
730
743
|
decision = json.loads(ns.plan_decision) if ns.plan_decision else None
|
|
744
|
+
authoring = (
|
|
745
|
+
json.loads(ns.report_authoring) if ns.report_authoring else None
|
|
746
|
+
)
|
|
731
747
|
created_at = dt.datetime.now(dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
732
748
|
p = write_sidecar(Path(ns.report), answers, decision, created_at,
|
|
733
|
-
task_key=ns.task_key)
|
|
749
|
+
task_key=ns.task_key, report_authoring=authoring)
|
|
734
750
|
json.dump({"sidecar": str(p)}, sys.stdout, ensure_ascii=False)
|
|
735
751
|
return 0
|
|
736
752
|
return 1
|
|
@@ -191,6 +191,27 @@
|
|
|
191
191
|
],
|
|
192
192
|
"description": "Lead-authored fallback is only valid for release-handoff or recorded report-writer dispatch failure."
|
|
193
193
|
},
|
|
194
|
+
"leadAuthoredFallback": {
|
|
195
|
+
"type": "object",
|
|
196
|
+
"description": "Why this run took the lead-authored fallback and who permitted it. Present exactly when reportAuthor is `Okstra lead` on a task type other than release-handoff. The approval passes the gate but does not retire it: this block is what a later reader sees, so the run does not read as a normal report-writer run once the sidecar is out of view.",
|
|
197
|
+
"required": [
|
|
198
|
+
"dispatchFailureReason",
|
|
199
|
+
"approvalSidecar"
|
|
200
|
+
],
|
|
201
|
+
"additionalProperties": false,
|
|
202
|
+
"properties": {
|
|
203
|
+
"dispatchFailureReason": {
|
|
204
|
+
"type": "string",
|
|
205
|
+
"minLength": 1,
|
|
206
|
+
"description": "The reason recorded on the failed report-writer dispatch row, verbatim."
|
|
207
|
+
},
|
|
208
|
+
"approvalSidecar": {
|
|
209
|
+
"type": "string",
|
|
210
|
+
"minLength": 1,
|
|
211
|
+
"description": "Project-relative path of the user-responses sidecar carrying the approving `## REPORT AUTHORING` block."
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
},
|
|
194
215
|
"leadModel": {
|
|
195
216
|
"type": "string",
|
|
196
217
|
"minLength": 1
|
|
@@ -34,7 +34,8 @@ schema-version: {{ schemaVersion | yaml_scalar }}
|
|
|
34
34
|
- Task Type: `{{ header.taskType }}`
|
|
35
35
|
- Report Owner: `{{ header.reportOwner }}`
|
|
36
36
|
- Report Author: `{{ header.reportAuthor }}`
|
|
37
|
-
- Lead
|
|
37
|
+
{% if header.leadAuthoredFallback %}- Lead-authored fallback: report-writer dispatch failed — `{{ header.leadAuthoredFallback.dispatchFailureReason }}`; permitted by the user in `{{ header.leadAuthoredFallback.approvalSidecar }}`
|
|
38
|
+
{% endif %}- Lead Model: `{{ header.leadModel | model_detail }}`
|
|
38
39
|
- Okstra Version: `{{ header.okstraVersion }}`
|
|
39
40
|
|
|
40
41
|
## AI Handoff Summary
|
|
@@ -3499,6 +3499,7 @@ def validate_final_report_data(
|
|
|
3499
3499
|
)
|
|
3500
3500
|
elif task_type == "implementation":
|
|
3501
3501
|
_validate_stage_carry_sidecar_exists(data, report_path, failures)
|
|
3502
|
+
_validate_lead_authored_report(data, report_path, failures)
|
|
3502
3503
|
if task_type == "error-analysis":
|
|
3503
3504
|
_validate_error_analysis_consistency(data, failures)
|
|
3504
3505
|
elif task_type == "final-verification":
|
|
@@ -6369,6 +6370,138 @@ def _validate_verifier_fail_blocks_verdict(data: dict, failures: list[str]) -> N
|
|
|
6369
6370
|
)
|
|
6370
6371
|
|
|
6371
6372
|
|
|
6373
|
+
|
|
6374
|
+
_LEAD_AUTHORED = "Okstra lead"
|
|
6375
|
+
_REPORT_AUTHORING_HEADING_RE = re.compile(r"^## REPORT AUTHORING\s*$", re.MULTILINE)
|
|
6376
|
+
_REPORT_AUTHORING_APPROVED = "approved"
|
|
6377
|
+
# `report-writer.md` "Lead-authored fallback": the attempt must have reached one
|
|
6378
|
+
# of these with a concrete reason. `completed` means the worker produced the
|
|
6379
|
+
# report, so the lead had nothing to fall back from.
|
|
6380
|
+
_DISPATCH_FAILURE_STATUSES = {"error", "timeout", "not-run"}
|
|
6381
|
+
|
|
6382
|
+
|
|
6383
|
+
def _report_authoring_approval(report_path: Path) -> str:
|
|
6384
|
+
"""The user's recorded answer on letting the lead author this report.
|
|
6385
|
+
|
|
6386
|
+
Read from the run's `user-responses/` sidecars, the same channel the
|
|
6387
|
+
clarification and plan-decision answers already use. The file is written by
|
|
6388
|
+
the user through `okstra user-response write`, which is the point: an
|
|
6389
|
+
approval the lead could author itself would be the self-report this gate
|
|
6390
|
+
exists to remove.
|
|
6391
|
+
"""
|
|
6392
|
+
sidecar_dir = report_path.parent.parent / "user-responses"
|
|
6393
|
+
if not sidecar_dir.is_dir():
|
|
6394
|
+
return ""
|
|
6395
|
+
for sidecar in sorted(sidecar_dir.glob("*.md")):
|
|
6396
|
+
try:
|
|
6397
|
+
text = sidecar.read_text(encoding="utf-8")
|
|
6398
|
+
except OSError:
|
|
6399
|
+
continue
|
|
6400
|
+
match = _REPORT_AUTHORING_HEADING_RE.search(text)
|
|
6401
|
+
if not match:
|
|
6402
|
+
continue
|
|
6403
|
+
block = text[match.end():]
|
|
6404
|
+
next_heading = re.search(r"^## ", block, re.MULTILINE)
|
|
6405
|
+
if next_heading:
|
|
6406
|
+
block = block[: next_heading.start()]
|
|
6407
|
+
status = re.search(r"^-\s*Status:\s*(.+?)\s*$", block, re.MULTILINE)
|
|
6408
|
+
if status:
|
|
6409
|
+
return status.group(1).strip()
|
|
6410
|
+
return ""
|
|
6411
|
+
|
|
6412
|
+
|
|
6413
|
+
def _validate_lead_authored_report(
|
|
6414
|
+
data: dict,
|
|
6415
|
+
report_path: Path,
|
|
6416
|
+
failures: list[str],
|
|
6417
|
+
) -> None:
|
|
6418
|
+
"""A lead-authored final report needs a failed dispatch AND a user approval.
|
|
6419
|
+
|
|
6420
|
+
`header.reportAuthor` renders in the report but nothing read it, so a lead
|
|
6421
|
+
could name itself the author with no dispatch behind it. The contract has
|
|
6422
|
+
always required a real attempt that recorded a terminal failure with a
|
|
6423
|
+
reason; this adds the second door, because a lead that dispatches once,
|
|
6424
|
+
lets it fail, and proceeds has still decided alone. Neither door retires
|
|
6425
|
+
the other: an approval does not excuse a missing attempt, and an attempt
|
|
6426
|
+
that failed is the cue to ask, not the permission.
|
|
6427
|
+
|
|
6428
|
+
The record is not consumed by passing. The failure reason and the approval
|
|
6429
|
+
both stay on disk, and `header.reportAuthor` stays `Okstra lead` in the
|
|
6430
|
+
rendered report, so a later reader sees that this run took the fallback and
|
|
6431
|
+
why.
|
|
6432
|
+
"""
|
|
6433
|
+
header = data.get("header")
|
|
6434
|
+
if not isinstance(header, Mapping):
|
|
6435
|
+
return
|
|
6436
|
+
if str(header.get("reportAuthor") or "").strip() != _LEAD_AUTHORED:
|
|
6437
|
+
return
|
|
6438
|
+
# release-handoff has no worker roster at all: it is single-lead by design,
|
|
6439
|
+
# so there is no dispatch to fail and nothing for the user to permit.
|
|
6440
|
+
if str(header.get("taskType") or "").strip() == "release-handoff":
|
|
6441
|
+
return
|
|
6442
|
+
|
|
6443
|
+
team_state = data.get("teamState")
|
|
6444
|
+
dispatches = (
|
|
6445
|
+
team_state.get("workerDispatches") if isinstance(team_state, Mapping) else None
|
|
6446
|
+
)
|
|
6447
|
+
attempts = [
|
|
6448
|
+
row
|
|
6449
|
+
for row in (dispatches if isinstance(dispatches, list) else [])
|
|
6450
|
+
if isinstance(row, Mapping)
|
|
6451
|
+
and str(row.get("workerId") or "").strip() == "report-writer"
|
|
6452
|
+
]
|
|
6453
|
+
failed = [
|
|
6454
|
+
row
|
|
6455
|
+
for row in attempts
|
|
6456
|
+
if str(row.get("status") or "").strip() in _DISPATCH_FAILURE_STATUSES
|
|
6457
|
+
]
|
|
6458
|
+
if not failed:
|
|
6459
|
+
failures.append(
|
|
6460
|
+
"final-report data.json: `header.reportAuthor` is `Okstra lead` but "
|
|
6461
|
+
"no report-writer dispatch recorded a terminal failure "
|
|
6462
|
+
f"({', '.join(sorted(_DISPATCH_FAILURE_STATUSES))}) in team-state. "
|
|
6463
|
+
"The lead-authored fallback is reachable only from an attempt that "
|
|
6464
|
+
"actually failed (prompts/lead/report-writer.md "
|
|
6465
|
+
"'Lead-authored fallback')"
|
|
6466
|
+
)
|
|
6467
|
+
elif not any(str(row.get("reason") or "").strip() for row in failed):
|
|
6468
|
+
failures.append(
|
|
6469
|
+
"final-report data.json: the report-writer dispatch failed but "
|
|
6470
|
+
"recorded no reason, so the lead-authored fallback rests on an "
|
|
6471
|
+
"unexplained failure. Record the tool error, the timeout, or the "
|
|
6472
|
+
"external blocker on the dispatch row"
|
|
6473
|
+
)
|
|
6474
|
+
|
|
6475
|
+
fallback = header.get("leadAuthoredFallback")
|
|
6476
|
+
if not isinstance(fallback, Mapping):
|
|
6477
|
+
failures.append(
|
|
6478
|
+
"final-report data.json: `header.reportAuthor` is `Okstra lead` but "
|
|
6479
|
+
"`header.leadAuthoredFallback` is absent. The approval passes the "
|
|
6480
|
+
"gate; it does not erase it — the failure reason and the approving "
|
|
6481
|
+
"sidecar belong in the report a human reads, not only in the "
|
|
6482
|
+
"sidecars they would have to go find"
|
|
6483
|
+
)
|
|
6484
|
+
else:
|
|
6485
|
+
recorded = str(fallback.get("dispatchFailureReason") or "").strip()
|
|
6486
|
+
reasons = {str(row.get("reason") or "").strip() for row in failed}
|
|
6487
|
+
if recorded and reasons and recorded not in reasons:
|
|
6488
|
+
failures.append(
|
|
6489
|
+
"final-report data.json: "
|
|
6490
|
+
"`header.leadAuthoredFallback.dispatchFailureReason` does not "
|
|
6491
|
+
"match any reason recorded on a failed report-writer dispatch. "
|
|
6492
|
+
"Quote the dispatch row verbatim rather than restating it"
|
|
6493
|
+
)
|
|
6494
|
+
|
|
6495
|
+
approval = _report_authoring_approval(report_path)
|
|
6496
|
+
if approval != _REPORT_AUTHORING_APPROVED:
|
|
6497
|
+
found = f"`{approval}`" if approval else "no `## REPORT AUTHORING` block"
|
|
6498
|
+
failures.append(
|
|
6499
|
+
"final-report data.json: `header.reportAuthor` is `Okstra lead` but "
|
|
6500
|
+
f"the run's `user-responses/` sidecars carry {found}. Only the user "
|
|
6501
|
+
"may permit the lead to author the report; ask at a gate and have "
|
|
6502
|
+
"the answer written through `okstra user-response write`"
|
|
6503
|
+
)
|
|
6504
|
+
|
|
6372
6505
|
def _validate_stage_carry_sidecar_exists(
|
|
6373
6506
|
data: dict,
|
|
6374
6507
|
report_path: Path,
|