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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "okstra",
3
- "version": "0.177.1",
3
+ "version": "0.178.0",
4
4
  "description": "Host-aware multi-provider cross-verification orchestrator runtime and agent skills.",
5
5
  "license": "MIT",
6
6
  "author": "devonshin",
@@ -1,5 +1,5 @@
1
1
  {
2
- "package": "0.177.1",
3
- "builtAt": "2026-08-18T18:45:29.329Z",
2
+ "package": "0.178.0",
3
+ "builtAt": "2026-08-18T18:51:00.094Z",
4
4
  "repoRoot": "/home/runner/work/okstra/okstra"
5
5
  }
@@ -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 is when a Report writer worker dispatch was actually attempted and recorded a terminal status of `error`/`timeout`/`not-run` with an explicit reason in team-state — see [report-writer](./report-writer.md) "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, infer_run_meta,
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 = "") -> Path:
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 Model: `{{ header.leadModel | model_detail }}`
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,