okstra 0.164.0 → 0.165.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/README.md +1 -1
  2. package/docs/architecture.md +12 -8
  3. package/docs/cli.md +7 -3
  4. package/docs/for-ai/README.md +2 -2
  5. package/docs/for-ai/skills/okstra-inspect.md +2 -2
  6. package/docs/for-ai/skills/okstra-user-response.md +2 -2
  7. package/docs/project-structure-overview.md +15 -9
  8. package/package.json +1 -1
  9. package/runtime/BUILD.json +2 -2
  10. package/runtime/agents/workers/antigravity-worker.md +9 -7
  11. package/runtime/agents/workers/codex-worker.md +9 -7
  12. package/runtime/agents/workers/grok-worker.md +6 -4
  13. package/runtime/agents/workers/kimi-worker.md +6 -4
  14. package/runtime/bin/okstra-antigravity-exec.sh +1 -340
  15. package/runtime/bin/okstra-claude-exec.sh +1 -178
  16. package/runtime/bin/okstra-codex-exec.sh +1 -467
  17. package/runtime/bin/okstra-provider-exec.py +165 -190
  18. package/runtime/bin/okstra-trace-cleanup.sh +14 -7
  19. package/runtime/bin/okstra-wrapper-status.py +26 -19
  20. package/runtime/prompts/lead/adapters/cmux.md +1 -1
  21. package/runtime/prompts/lead/convergence.md +36 -8
  22. package/runtime/prompts/lead/okstra-lead-contract.md +23 -1
  23. package/runtime/prompts/lead/plan-body-verification.md +9 -1
  24. package/runtime/prompts/lead/report-writer.md +1 -0
  25. package/runtime/prompts/lead/team-contract.md +3 -3
  26. package/runtime/prompts/profiles/_common-contract.md +9 -1
  27. package/runtime/prompts/profiles/_coverage-critic.md +1 -1
  28. package/runtime/prompts/profiles/_implementation-diff-review.md +3 -1
  29. package/runtime/prompts/profiles/_implementation-self-check.md +1 -1
  30. package/runtime/prompts/profiles/_implementation-verifier.md +3 -1
  31. package/runtime/prompts/profiles/implementation-planning.md +5 -3
  32. package/runtime/python/okstra_ctl/adapters/hosts/claude-code/relay.md +1 -1
  33. package/runtime/python/okstra_ctl/adapters/hosts/external/relay.md +1 -1
  34. package/runtime/python/okstra_ctl/adapters/providers/antigravity/adapter.py +148 -0
  35. package/runtime/python/okstra_ctl/adapters/providers/claude/adapter.py +55 -0
  36. package/runtime/python/okstra_ctl/adapters/providers/codex/adapter.py +41 -0
  37. package/runtime/python/okstra_ctl/adapters/providers/grok/adapter.py +44 -0
  38. package/runtime/python/okstra_ctl/adapters/providers/kimi/adapter.py +42 -0
  39. package/runtime/python/okstra_ctl/dispatch_core.py +5 -1
  40. package/runtime/python/okstra_ctl/dispatch_state.py +10 -0
  41. package/runtime/python/okstra_ctl/domain/provider.py +5 -1
  42. package/runtime/python/okstra_ctl/domain/worker_exec.py +102 -0
  43. package/runtime/python/okstra_ctl/domain/worker_role.py +34 -0
  44. package/runtime/python/okstra_ctl/domain/worker_stream.py +261 -0
  45. package/runtime/python/okstra_ctl/incremental_scope.py +16 -4
  46. package/runtime/python/okstra_ctl/report_html/common.py +71 -25
  47. package/runtime/python/okstra_ctl/report_html/models.py +5 -0
  48. package/runtime/python/okstra_ctl/report_html/render.py +1 -1
  49. package/runtime/python/okstra_ctl/report_html/run_usage.py +19 -0
  50. package/runtime/python/okstra_ctl/report_html/view_models/implementation_planning.py +14 -0
  51. package/runtime/python/okstra_ctl/report_views.py +44 -16
  52. package/runtime/python/okstra_ctl/stage_citations.py +52 -15
  53. package/runtime/python/okstra_ctl/user_response.py +45 -29
  54. package/runtime/python/okstra_ctl/wizard.py +13 -9
  55. package/runtime/python/okstra_ctl/worker_prompt_policy.py +10 -3
  56. package/runtime/python/okstra_ctl/worker_request.py +140 -0
  57. package/runtime/python/okstra_ctl/worker_runner.py +622 -0
  58. package/runtime/python/okstra_token_usage/collect.py +8 -1
  59. package/runtime/python/okstra_token_usage/report.py +42 -0
  60. package/runtime/python/okstra_token_usage/task_totals.py +88 -0
  61. package/runtime/schemas/final-report-v1.0.schema.json +70 -0
  62. package/runtime/schemas/final-report-v2.0.schema.json +90 -0
  63. package/runtime/skills/okstra-inspect/SKILL.md +1 -2
  64. package/runtime/skills/okstra-inspect/facets/logs.md +5 -5
  65. package/runtime/skills/okstra-inspect/facets/run-audit.md +3 -3
  66. package/runtime/skills/okstra-run/SKILL.md +1 -1
  67. package/runtime/skills/okstra-user-response/SKILL.md +15 -5
  68. package/runtime/templates/report-writer-prompt-preamble.md +1 -0
  69. package/runtime/templates/reports/html/assets/base.css +8 -4
  70. package/runtime/templates/reports/html/base.template.html +12 -6
  71. package/runtime/templates/reports/html/i18n/en.json +29 -6
  72. package/runtime/templates/reports/html/i18n/ko.json +29 -6
  73. package/runtime/templates/reports/html/macros/forms.html +9 -3
  74. package/runtime/templates/reports/html/tasks/implementation-planning.template.html +14 -19
  75. package/runtime/templates/reports/report.js +59 -26
  76. package/runtime/templates/reports/user-response.template.md +12 -8
  77. package/runtime/validators/validate-run.py +88 -7
  78. package/runtime/validators/validate_session_conformance.py +62 -1
  79. package/src/cli-registry.mjs +0 -7
  80. package/runtime/bin/okstra-wrapper-agy-stream.py +0 -61
  81. package/runtime/python/okstra_ctl/error_issue.py +0 -640
  82. package/runtime/python/okstra_ctl/issue_signals.py +0 -186
  83. package/runtime/skills/okstra-inspect/facets/error-issue.md +0 -77
  84. package/src/commands/inspect/error-issue.mjs +0 -27
@@ -66,7 +66,7 @@ from okstra_ctl.report_translation import ( # noqa: E402
66
66
  HANGUL_PROSE_LIMIT,
67
67
  hangul_share,
68
68
  )
69
- from okstra_ctl.stage_citations import cited_stage_numbers # noqa: E402
69
+ from okstra_ctl.stage_citations import enumerated_stage_numbers # noqa: E402
70
70
  from okstra_ctl.incremental_scope import ( # noqa: E402
71
71
  coverage_row_blocked_on,
72
72
  stages_for_clarification,
@@ -3133,6 +3133,7 @@ def validate_final_report_data(
3133
3133
  # Called here rather than from a task-type branch: four profiles raise
3134
3134
  # clarification rows, and the gate scopes itself by task type internally.
3135
3135
  _validate_clarification_options(data, failures)
3136
+ _validate_open_approval_blocker_provenance(data, failures)
3136
3137
 
3137
3138
  task_type = (data.get("header") or {}).get("taskType")
3138
3139
  _validate_verifier_fail_blocks_verdict(data, failures)
@@ -4126,6 +4127,80 @@ def _validate_option_set(
4126
4127
  )
4127
4128
 
4128
4129
 
4130
+ def _validate_open_approval_blocker_provenance(
4131
+ data: dict, failures: list[str]
4132
+ ) -> None:
4133
+ """An open approval blocker records who raised it and what the lead did about it.
4134
+
4135
+ A lead can foresee a blocker, write it into the report as a
4136
+ `blocks: approval` row, and let the run finish — the row then withholds
4137
+ approval until a separate user-response cycle answers it. That path cost a
4138
+ full planning run: the user pointed at a reduction document and said "remove
4139
+ what it flags", the lead read that document's own "user decision required"
4140
+ note as outranking the instruction, and told the report writer to raise the
4141
+ item instead of applying it. The two verifiers then agreed unanimously that
4142
+ it was a user decision — not an independent finding, but the lead's own
4143
+ instruction returning to it. The run spent its whole self-fix budget on a
4144
+ gate no round could clear, because the thing blocking it was already
4145
+ answered before the run started.
4146
+
4147
+ `origin` names who produced the row, so a lead-authored blocker can no
4148
+ longer arrive dressed as a worker consensus. `userConfirmation` records what
4149
+ the lead did before writing it. Neither is required on a row the user has
4150
+ already answered (`answered` / `resolved`) — the record is in `userInput`.
4151
+
4152
+ The one shape rejected outright is a lead-directed blocker raised with no
4153
+ interactive session: with nobody to ask, a defect the lead itself surfaced
4154
+ belongs in `## 5. Missing Information and Risks` as a Working Assumption,
4155
+ the same outlet the surviving planner-fixable items already use. Blocking a
4156
+ plan on the lead's own judgment, in a run where that judgment cannot be put
4157
+ to the user, only defers the work to a re-run.
4158
+ """
4159
+ for row in data.get("clarificationItems") or []:
4160
+ if not isinstance(row, dict):
4161
+ continue
4162
+ if row.get("blocks") != "approval" or row.get("status") != "open":
4163
+ continue
4164
+ row_id = str(row.get("id") or "<unknown>")
4165
+ origin = row.get("origin")
4166
+ confirmation = row.get("userConfirmation")
4167
+ if not origin:
4168
+ failures.append(
4169
+ f"final-report data.json: clarification `{row_id}` is an open "
4170
+ "approval blocker with no `origin`. Record who raised it — "
4171
+ "`worker-finding` (an analyser or verifier reached it "
4172
+ "independently), `material-gap` (the brief and the codebase "
4173
+ "both leave it unanswered), or `lead-directed` (the lead's own "
4174
+ "judgment, including anything the lead instructed a worker to "
4175
+ "raise). A lead-authored blocker recorded as a worker consensus "
4176
+ "is how a run blocks on a question the user already answered."
4177
+ )
4178
+ if not confirmation:
4179
+ failures.append(
4180
+ f"final-report data.json: clarification `{row_id}` is an open "
4181
+ "approval blocker with no `userConfirmation`. Record what "
4182
+ "happened before the row was written — `asked-and-answered`, "
4183
+ "`asked-awaiting`, or `deferred-no-interactive-session`. "
4184
+ "Withholding approval is the most expensive thing a report can "
4185
+ "do to a run; doing it without recording whether the user was "
4186
+ "ever asked leaves the next run unable to tell a real blocker "
4187
+ "from a question nobody put to them."
4188
+ )
4189
+ if (
4190
+ origin == "lead-directed"
4191
+ and confirmation == "deferred-no-interactive-session"
4192
+ ):
4193
+ failures.append(
4194
+ f"final-report data.json: clarification `{row_id}` blocks "
4195
+ "approval on the lead's own judgment (`origin: lead-directed`) "
4196
+ "in a run with no interactive session to answer it. Record it "
4197
+ "as a Working Assumption in `## 5. Missing Information and "
4198
+ "Risks` and let the plan proceed — that is the outlet surviving "
4199
+ "planner-fixable items already use. A blocker nobody can answer "
4200
+ "inside this run only moves the work to a re-run."
4201
+ )
4202
+
4203
+
4129
4204
  def _has_clarification_backtrace(
4130
4205
  row_id: str, plan_items: object, coverage: object
4131
4206
  ) -> bool:
@@ -6347,10 +6422,14 @@ def _validate_requirement_provenance(
6347
6422
  # Deliberately not `_COVERED_BY_STAGE_REF_RE`: the two directions disagree on
6348
6423
  # what "no match" means — forward treats it as nothing to verify and passes,
6349
6424
  # reverse treats it as nothing cited and orphans every stage — so the reverse
6350
- # reader must cover `Stages 1, 2`, `Stages 1-3` and `Stage 1 and 2` too. The
6351
- # grammar is shared with the incremental-scope back-trace, which resolves the
6352
- # same citations in the opposite direction.
6353
- _cited_stage_numbers = cited_stage_numbers
6425
+ # reader must cover `Stages 1, 2` and `Stage 1 and 2` too.
6426
+ #
6427
+ # It reads hand-enumerated numbers only. A range's interior is deliberately NOT
6428
+ # coverage evidence: one `Stages 1-64` cell would otherwise stamp every stage in
6429
+ # the map while the planner confirmed none of them. The incremental-scope
6430
+ # back-trace keeps the widening reader (`cited_stage_numbers`) because "could
6431
+ # this answer reach stage 5" is the opposite question and must over-approximate.
6432
+ _enumerated_stage_numbers = enumerated_stage_numbers
6354
6433
 
6355
6434
 
6356
6435
  # Statuses under which a row asserts the plan does work for the requirement.
@@ -6387,14 +6466,16 @@ def _validate_stage_has_requirement(data: dict, failures: list[str]) -> None:
6387
6466
  continue
6388
6467
  if not _COVERAGE_CLAIMING_STATUS_RE.match(str(row.get("status") or "").strip()):
6389
6468
  continue
6390
- cited |= _cited_stage_numbers(str(row.get("coveredBy") or ""))
6469
+ cited |= _enumerated_stage_numbers(str(row.get("coveredBy") or ""))
6391
6470
 
6392
6471
  for orphan in sorted(stage_numbers - cited):
6393
6472
  failures.append(
6394
6473
  f"final-report data.json: Stage {orphan} is cited by no requirementCoverage "
6395
6474
  "row — it traces back to nothing the brief asked for. Either cite the "
6396
6475
  "requirement it serves, or drop it and raise it as a `Blocks=approval` "
6397
- "clarification (profile: scope provenance)."
6476
+ "clarification (profile: scope provenance). A range cites only its "
6477
+ "endpoints here: write `Stages 1, 2, 3` rather than `Stages 1-3`, so each "
6478
+ "stage this requirement covers is one the planner named."
6398
6479
  )
6399
6480
 
6400
6481
 
@@ -457,6 +457,61 @@ def _plan_body_rounds_ran(run_dir: Path, suffix: str | None) -> int:
457
457
  return rounds if isinstance(rounds, int) and rounds > 0 else 0
458
458
 
459
459
 
460
+ def _ids_reported_as_asked(report_path: Path) -> list[str]:
461
+ """리포트가 "사용자에게 물었다"고 기록한 열린 승인 차단 행의 id."""
462
+ name = report_path.name
463
+ if not name.endswith(".md"):
464
+ return []
465
+ data_path = report_path.with_name(name.removesuffix(".md") + ".data.json")
466
+ try:
467
+ doc = json.loads(data_path.read_text())
468
+ except (OSError, json.JSONDecodeError):
469
+ return []
470
+ if not isinstance(doc, dict):
471
+ return []
472
+ asked: list[str] = []
473
+ for row in doc.get("clarificationItems") or []:
474
+ if not isinstance(row, dict):
475
+ continue
476
+ if row.get("blocks") != "approval" or row.get("status") != "open":
477
+ continue
478
+ if not str(row.get("userConfirmation") or "").startswith("asked-"):
479
+ continue
480
+ row_id = str(row.get("id") or "").strip()
481
+ if row_id:
482
+ asked.append(row_id)
483
+ return asked
484
+
485
+
486
+ def _check_user_confirm_checkpoints(
487
+ by_phase: dict[str, list[tuple[str, str]]],
488
+ asked_ids: list[str],
489
+ errors: list[str],
490
+ ) -> None:
491
+ """"물어봤다"는 리포트의 기록에는 물어본 흔적이 세션에 남아 있어야 한다.
492
+
493
+ 승인을 막는 행은 run 하나를 통째로 대기 상태로 만든다. 그런 행을 세우기 전에
494
+ 사용자에게 그 자리에서 묻는 것이 계약인데, 그 이행 여부를 리포트의 자기 신고
495
+ 말고는 확인할 길이 없었다. 한 번은 리드가 차단이 생길 것을 미리 예측해 말해
496
+ 놓고도 묻지 않은 채 행으로 만들었고, run 은 사용자가 이미 답해 둔 질문 앞에서
497
+ self-fix 예산을 전부 썼다. 체크포인트가 그 격차를 좁힌다 — 세션에 질문이 남지
498
+ 않았다면 리드는 묻지 않은 것이다."""
499
+ if not asked_ids:
500
+ return
501
+ detail = "prompts/lead/okstra-lead-contract.md 'User confirmation before an approval blocker'"
502
+ asked_lines = " ".join(line for _ts, line in by_phase.get("user-confirm", []))
503
+ for row_id in asked_ids:
504
+ if row_id in asked_lines:
505
+ continue
506
+ errors.append(
507
+ f"PROGRESS checkpoint missing: `user-confirm {row_id}` — the final "
508
+ f"report records `{row_id}` as an approval blocker the user was "
509
+ "asked about, but no such line exists in this run's evidence. Either "
510
+ "ask before writing the row, or record what actually happened "
511
+ f"(`deferred-no-interactive-session`) ({detail})."
512
+ )
513
+
514
+
460
515
  def _phase_mentions_worker(lines: list[tuple[str, str]], needles: list[str]) -> bool:
461
516
  return any(needle in _norm(line) for _ts, line in lines for needle in needles)
462
517
 
@@ -560,6 +615,7 @@ def _check_progress_checkpoints(
560
615
  team_state: dict,
561
616
  run_dir: Path,
562
617
  suffix: str | None,
618
+ report_path: Path,
563
619
  errors: list[str],
564
620
  ) -> None:
565
621
  by_phase: dict[str, list[tuple[str, str]]] = {}
@@ -622,6 +678,9 @@ def _check_progress_checkpoints(
622
678
  _check_plan_verify_cleanup_checkpoints(
623
679
  by_phase, _plan_body_rounds_ran(run_dir, suffix), errors
624
680
  )
681
+ _check_user_confirm_checkpoints(
682
+ by_phase, _ids_reported_as_asked(report_path), errors
683
+ )
625
684
 
626
685
 
627
686
  def _parse_iso(ts: str) -> datetime | None:
@@ -874,7 +933,9 @@ def validate_session_conformance(
874
933
  if error:
875
934
  result.errors.append(error)
876
935
  return result
877
- _check_progress_checkpoints(evidence, team_state, run_dir, suffix, result.errors)
936
+ _check_progress_checkpoints(
937
+ evidence, team_state, run_dir, suffix, report_path, result.errors
938
+ )
878
939
  _check_cmux_adapter_read(evidence, team_state, result.errors)
879
940
  if task_type == "implementation":
880
941
  _check_implementation_entry_guard(
@@ -218,13 +218,6 @@ export const COMMAND_REGISTRY = [
218
218
  category: "introspection",
219
219
  summary: ["Bundle cross-project okstra error logs into a zip"],
220
220
  },
221
- {
222
- name: "error-issue",
223
- module: "./commands/inspect/error-issue.mjs",
224
- export: "run",
225
- category: "introspection",
226
- summary: ["Turn okstra run anomalies into GitHub issue candidates"],
227
- },
228
221
  {
229
222
  name: "run-audit",
230
223
  module: "./commands/inspect/run-audit.mjs",
@@ -1,61 +0,0 @@
1
- #!/usr/bin/env python3
2
- """Split `agy --output-format stream-json` output for okstra-antigravity-exec.sh.
3
-
4
- Everything agy emits is appended to the live log, so the log becomes an audit
5
- trail of what the worker actually did — which tools it called, on which paths,
6
- for how long. In the default `text` format agy prints only its closing summary,
7
- so an antigravity dispatch left ~2KB of self-report against codex's ~487KB of
8
- tool trace, and whether the worker ever opened the evidence it was asked to
9
- verify could not be checked from outside.
10
-
11
- stdout stays what the dispatcher's contract expects: the final response text
12
- only, so the subagent's captured output is unchanged by the format switch.
13
- """
14
- from __future__ import annotations
15
-
16
- import json
17
- import sys
18
-
19
-
20
- def main() -> int:
21
- if len(sys.argv) < 2:
22
- print("usage: okstra-wrapper-agy-stream.py <log-path>", file=sys.stderr)
23
- return 2
24
- log_path = sys.argv[1]
25
- final: str | None = None
26
- # Lines agy emitted that are not stream-json events. Normally empty; kept so
27
- # a non-JSON emitter (a stub, an older agy, a crash banner) still reaches
28
- # stdout instead of being silently swallowed.
29
- passthrough: list[str] = []
30
- with open(log_path, "a", encoding="utf-8") as log:
31
- for line in sys.stdin:
32
- log.write(line)
33
- log.flush()
34
- stripped = line.strip()
35
- if not stripped:
36
- continue
37
- try:
38
- event = json.loads(stripped)
39
- except json.JSONDecodeError:
40
- passthrough.append(line)
41
- continue
42
- if isinstance(event, dict) and event.get("event") == "result":
43
- result = event.get("result")
44
- if isinstance(result, dict):
45
- final = result.get("response") or ""
46
- if final is not None:
47
- sys.stdout.write(final)
48
- elif passthrough:
49
- sys.stdout.writelines(passthrough)
50
- else:
51
- print(
52
- "okstra-wrapper-agy-stream: no result event in agy output — "
53
- f"see {log_path}",
54
- file=sys.stderr,
55
- )
56
- sys.stdout.flush()
57
- return 0
58
-
59
-
60
- if __name__ == "__main__":
61
- raise SystemExit(main())