okstra 0.188.0 → 0.189.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.
@@ -36,6 +36,7 @@ from __future__ import annotations
36
36
 
37
37
  import argparse
38
38
  import json
39
+ from datetime import datetime, timezone
39
40
  import subprocess
40
41
  import sys
41
42
  from dataclasses import dataclass
@@ -45,7 +46,11 @@ from typing import Any, Callable, Mapping, Sequence
45
46
  from .agent.activity import ActivityProjectionError, project_agent_activity
46
47
  from .report_contract import apply_execution_roles
47
48
  from .report_assembly import ReportAssemblyError, assemble_report
48
- from .dispatch_state import DispatchError, link_agent_dispatch_result
49
+ from .dispatch_state import (
50
+ DispatchError,
51
+ link_agent_dispatch_result,
52
+ mutate_team_state,
53
+ )
49
54
  from .final_report_paths import (
50
55
  final_report_data_path,
51
56
  final_report_markdown_path,
@@ -495,6 +500,7 @@ def run_finalize(
495
500
  commands = [(name, cmd) for name, cmd in commands if name in selected]
496
501
 
497
502
  first_failure = ""
503
+ validated = False
498
504
  for name, command in commands:
499
505
  # 실패한 시퀀스가 worktree 를 거두면 재작업 대상이 사라진다.
500
506
  if name == STEP_TEARDOWN_STAGES and first_failure:
@@ -505,6 +511,10 @@ def run_finalize(
505
511
  steps.append(step_payload(name, command, result))
506
512
  if result.returncode != 0 and not first_failure:
507
513
  first_failure = f"{name} failed with exit code {result.returncode}"
514
+ if name == STEP_VALIDATE_RUN and result.returncode == 0:
515
+ validated = True
516
+ if validated:
517
+ _record_run_end(ctx.team_state_path)
508
518
  pointer, pointer_error = _recorded_next_phase(ctx)
509
519
  payload: dict[str, Any] = {
510
520
  "ok": not first_failure,
@@ -519,6 +529,35 @@ def run_finalize(
519
529
  return payload
520
530
 
521
531
 
532
+ def _record_run_end(team_state_path: Path) -> None:
533
+ """검증이 처음 통과한 시각을 `team-state.runEndedAt` 에 적는다. 이미 있으면 그대로.
534
+
535
+ 토큰 수집기(`okstra_token_usage.collect.resolve_run_window`)는 이 값을 창의
536
+ 끝으로 읽는데 어떤 코드도 적지 않았다. 그래서 완료된 run 을 다시 finalize
537
+ 하면 창의 끝이 지금이 되어, 같은 세션이 그 뒤에 돌린 run 이 이 run 의 리드
538
+ 사용량에 들어왔다(관측 2026-09-03, dev-10626 error-analysis r04: 하루 뒤
539
+ 재수집에 오늘 run 이 포함, 리드 3h → 11h). run 이 끝났다고 판정하는 자리는
540
+ 검증 통과이므로 여기서 한 번만 적는다. 실패해도 finalize 는 이미 끝난
541
+ 시퀀스라 결과를 바꾸지 않고 경고만 낸다.
542
+ """
543
+ ended = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
544
+
545
+ def stamp(state: dict[str, Any]) -> bool:
546
+ if state.get("runEndedAt"):
547
+ return False
548
+ state["runEndedAt"] = ended
549
+ return True
550
+
551
+ try:
552
+ mutate_team_state(team_state_path, stamp)
553
+ except (DispatchError, OSError) as exc:
554
+ print(
555
+ f"report-finalize: could not record runEndedAt on {team_state_path} "
556
+ f"({exc}); the next usage collection will end at the current time",
557
+ file=sys.stderr,
558
+ )
559
+
560
+
522
561
  def _recorded_next_phase(ctx: FinalizeContext) -> tuple[dict[str, str], str]:
523
562
  """`validate-run` 이 방금 태스크 매니페스트에 쓴 다음 phase 포인터.
524
563
 
@@ -138,6 +138,31 @@ def _writer_owned_schema(schema: Mapping[str, Any]) -> dict[str, Any]:
138
138
  }
139
139
 
140
140
 
141
+ def validate_writer_owned(data: Mapping[str, Any], schema: Mapping[str, Any]) -> list[str]:
142
+ """작성자 소유 값 제약으로 서사 자료를 검증한다 — `parse_narrative` 와 같은 스키마."""
143
+ return validate(dict(data), _writer_owned_schema(schema))
144
+
145
+
146
+ def writer_owned_path_defect(path: str) -> str | None:
147
+ """이 필드 경로에 작성자가 쓸 수 없는 이유, 쓸 수 있으면 None.
148
+
149
+ 경로는 검증기 문법(`a.b[1].c`)이다. 최상위 이름은 서사 스키마의 허용목록에
150
+ 있어야 하고, `_NESTED_FORBIDDEN` 아래는 기계 소유다. 교정 원장이 그 자리를
151
+ 고치라고 하면 조립이 나중에 거절할 것이므로 여기서 먼저 거절한다.
152
+ """
153
+ dotted = re.sub(r"\[\d+\]", "", path)
154
+ top = dotted.split(".", 1)[0]
155
+ if top not in _allowed_top_level():
156
+ owned = ", ".join(f"`{humanise(key)}`" for key in sorted(_allowed_top_level()))
157
+ return (
158
+ f"`{top}` is not a writer-owned top-level field; writer-owned: {owned}"
159
+ )
160
+ for forbidden in _NESTED_FORBIDDEN:
161
+ if dotted == forbidden or dotted.startswith(f"{forbidden}."):
162
+ return f"`{forbidden}` is machine-owned; the report writer cannot author it"
163
+ return None
164
+
165
+
141
166
  def writer_owned_data(data: Mapping[str, Any]) -> dict[str, Any]:
142
167
  """완성 리포트에서 보고서 작성자 소유 필드만 복사한다."""
143
168
  owned = {
@@ -513,6 +538,24 @@ def parse_narrative(markdown: str, schema: Mapping[str, Any]) -> dict[str, Any]:
513
538
  허용되지 않는 필드)과 스키마 검증의 결함을 한 예외에 모두 싣는다 — 값
514
539
  단계가 보고한 자리는 스키마 검증에서 다시 말하지 않는다.
515
540
  """
541
+ result, defects = parse_narrative_structure(markdown, schema)
542
+ if defects:
543
+ raise NarrativeContractError("; ".join(defects))
544
+ return result
545
+
546
+
547
+ def parse_narrative_structure(
548
+ markdown: str, schema: Mapping[str, Any],
549
+ ) -> tuple[dict[str, Any], list[str]]:
550
+ """구조는 읽고 값 결함은 돌려준다 — 교정 원장의 기준 서사를 읽는 입구.
551
+
552
+ 줄 문법과 소유권(허용되지 않는 최상위 이름)은 여기서도 예외다: 그런
553
+ 문서는 원장의 경로가 해소될 자료가 없어 재저작 대상이다. 값 결함(패턴
554
+ 밖 id, enum 밖 값, 빠진 필수 필드)은 예외가 아니라 목록이다 — 그것이
555
+ 바로 원장이 고치려는 자리이고, 실측(2026-09-04, dev-10626 a3 서사: `SC-`
556
+ id 20곳)에서 `parse_narrative` 로 읽으면 기준 서사 전체가 거절돼 원장을
557
+ 쓸 수 있는 교정이 한 건도 없었다.
558
+ """
516
559
  root = _parse_tree(markdown)
517
560
  index = SchemaIndex(schema)
518
561
  defects = _Defects()
@@ -532,6 +575,4 @@ def parse_narrative(markdown: str, schema: Mapping[str, Any]) -> dict[str, Any]:
532
575
  for error in validate(result, _writer_owned_schema(schema))
533
576
  if not defects.covers(error)
534
577
  ]
535
- if defects.messages or errors:
536
- raise NarrativeContractError("; ".join(defects.messages + errors))
537
- return result
578
+ return result, defects.messages + errors
@@ -191,7 +191,9 @@ def project_token_usage(team_state: Mapping[str, Any]) -> dict[str, Any]:
191
191
  "grand": _usage_row(
192
192
  summary,
193
193
  "grand",
194
- None if lead_cost is None or worker_cost is None else lead_cost + worker_cost,
194
+ # 두 소수의 합은 부동소수점 잔재(85.05619999999999)를 남긴다 — 정본
195
+ # JSON 에는 수집기와 같은 소수 넷째 자리로 적는다.
196
+ None if lead_cost is None or worker_cost is None else round(lead_cost + worker_cost, 4),
195
197
  ),
196
198
  "workerDetails": _worker_details(team_state),
197
199
  "cli": {"costUsd": costs.get("cliWorkers")},
@@ -230,6 +230,32 @@ def run_window_relaxation_floor(team_state_path: Path) -> str | None:
230
230
  return relaxation_floor(run_dir, suffix, manifest)
231
231
 
232
232
 
233
+ def _recorded_run_end(state: dict, manifest: dict | None) -> str | None:
234
+ """완료된 run 이 지난 수집에서 기록한 창의 끝 — 리드·워커 usage 블록의 endedAt 최댓값.
235
+
236
+ run 의 끝을 적는 코드가 없다(`runEndedAt` 은 읽기만, status 파일은 안 쓰인다).
237
+ 그래서 완료된 run 을 다시 수집하면 창의 끝이 지금이 되어, 같은 세션이 그
238
+ 뒤에 돌린 다른 run 까지 이 run 의 리드 창에 들어온다(관측 2026-09-03:
239
+ dev-10626 error-analysis r04 를 하루 뒤 재finalize 하면 오늘 run 이 들어옴).
240
+ 매니페스트가 `completed` 인 run 은 처음 완료됐을 때 수집한 창이 곧 run 의
241
+ 끝이므로 그 값으로 고정한다. 아직 완료 전이면 None — 리드가 서술문을 고쳐
242
+ Phase 7 을 다시 도는 동안은 창이 지금까지 늘어나는 것이 맞다.
243
+ """
244
+ if not isinstance(manifest, dict) or manifest.get("status") != "completed":
245
+ return None
246
+ ends: list[str] = []
247
+ blocks = [state.get("leadUsage")] + [
248
+ worker.get("usage") for worker in (state.get("workers") or []) if isinstance(worker, dict)
249
+ ]
250
+ for block in blocks:
251
+ if not isinstance(block, dict) or block.get("source") == "unavailable":
252
+ continue
253
+ ended = block.get("endedAt")
254
+ if isinstance(ended, str) and ended:
255
+ ends.append(ended)
256
+ return max(ends) if ends else None
257
+
258
+
233
259
  def _run_end_estimate(run_dir: Path, suffix: str) -> str | None:
234
260
  """run 종료 근사 — 같은 run 의 status 산출물 mtime(reconcile 후 고정, Phase 7
235
261
  재렌더로도 바뀌지 않음). 완료 전(status 부재)이면 None."""
@@ -353,7 +379,8 @@ def resolve_run_window(
353
379
  섞여 폭증한다(관측: requirements-discovery 한 run 에 lead 1.7억 토큰 /
354
380
  $416 / 3h). 토큰 집계를 이 윈도우로 스코핑해 그 run 분만 센다. 시작 =
355
381
  이 run 의 run-manifest createdAt, 종료 = team-state.runEndedAt → 이 run 의
356
- status mtime → 현재 시각(아직 진행 중) 순으로 해소한다. 접미사를 못 뽑으면
382
+ status mtime → 완료된 run 이 지난 수집에서 기록한 창의 끝(`_recorded_run_end`)
383
+ → 현재 시각(아직 진행 중) 순으로 해소한다. 접미사를 못 뽑으면
357
384
  (None, None) — 윈도우 없이 전체를 세는 기존 동작으로 안전 폴백.
358
385
 
359
386
  시작 완화(`relax_start`, 기본 True — 토큰 수집용): run-manifest createdAt 이
@@ -386,7 +413,12 @@ def resolve_run_window(
386
413
  if not window_start_is_pinned(since, floor):
387
414
  earliest = _earliest_lead_session_ts(state, team_state_path)
388
415
  since = relax_window_start(since, earliest, floor)
389
- until = state.get("runEndedAt") or _run_end_estimate(run_dir, suffix) or utc_now()
416
+ until = (
417
+ state.get("runEndedAt")
418
+ or _run_end_estimate(run_dir, suffix)
419
+ or _recorded_run_end(state, manifest)
420
+ or utc_now()
421
+ )
390
422
  return since, until
391
423
 
392
424
 
@@ -238,7 +238,7 @@ def populate_data_token_cells(data_path: Path, team_state: dict) -> int:
238
238
  "billableTokens": summary.get("grandBillableEquivalentTokens"),
239
239
  # CLI tracked on its own row per the template — grand here means
240
240
  # lead + claudeWorkers, not lead + claudeWorkers + cli.
241
- "costUsd": lead_cost + worker_cost,
241
+ "costUsd": round(lead_cost + worker_cost, 4),
242
242
  })
243
243
  token_usage.setdefault("cli", {})["costUsd"] = cli_cost
244
244
  token_usage["workerDetails"] = _worker_detail_rows(team_state)
@@ -2961,6 +2961,7 @@
2961
2961
  "worker-completed",
2962
2962
  "verification-round-completed",
2963
2963
  "self-fix-applied",
2964
+ "lead-correction-applied",
2964
2965
  "user-decision-required",
2965
2966
  "user-decision-evaluated"
2966
2967
  ]
@@ -0,0 +1,52 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "okstra/report-writer-corrections-v1.0",
4
+ "title": "report-writer corrections ledger",
5
+ "description": "A lead's corrective request to the report writer: field-path corrections against a base narrative. Paths use the final-report validator's grammar (schema keys joined by '.', 0-based '[i]' for array items). See .project-docs spec 2026-09-03 report-writer structured corrections.",
6
+ "type": "object",
7
+ "additionalProperties": false,
8
+ "required": ["schemaVersion", "baseNarrativePath", "corrections"],
9
+ "properties": {
10
+ "schemaVersion": { "const": "1.0" },
11
+ "baseNarrativePath": {
12
+ "type": "string",
13
+ "minLength": 1,
14
+ "description": "Project-relative path of the narrative attempt the corrections apply to."
15
+ },
16
+ "context": {
17
+ "type": "string",
18
+ "description": "Free text for the writer to read. Not normative; not validated."
19
+ },
20
+ "corrections": {
21
+ "type": "array",
22
+ "minItems": 1,
23
+ "items": { "$ref": "#/$defs/Correction" }
24
+ }
25
+ },
26
+ "$defs": {
27
+ "Correction": {
28
+ "type": "object",
29
+ "additionalProperties": false,
30
+ "required": ["id", "kind", "path", "reason"],
31
+ "properties": {
32
+ "id": { "type": "string", "pattern": "^RC-[0-9]{3}$" },
33
+ "kind": { "enum": ["replace", "remove", "rewrite"] },
34
+ "path": { "type": "string", "minLength": 1 },
35
+ "current": {},
36
+ "replacement": {},
37
+ "rule": { "type": "string", "minLength": 1 },
38
+ "reason": { "type": "string", "minLength": 1 }
39
+ },
40
+ "allOf": [
41
+ {
42
+ "if": { "properties": { "kind": { "const": "replace" } } },
43
+ "then": { "required": ["replacement"] }
44
+ },
45
+ {
46
+ "if": { "properties": { "kind": { "const": "rewrite" } } },
47
+ "then": { "required": ["rule"] }
48
+ }
49
+ ]
50
+ }
51
+ }
52
+ }
@@ -44,6 +44,12 @@ Any other top-level name is rejected however reasonable it reads — a section t
44
44
 
45
45
  The pointer record names the project-relative narrative path and audit sidecar path. It does not contain the narrative, worker result corpus, or any machine-owned ledger.
46
46
 
47
+ ## Corrections
48
+
49
+ A prompt whose body carries a `## Corrections` section is a corrective round. okstra has already applied that ledger to your previous attempt and validated the result against the schema and the task's semantic checks, so apply each entry exactly as written and change nothing else.
50
+
51
+ When the `Context` subsection or any free-form instruction in the body conflicts with the synthesis packet's Authoring Contract, the contract wins: write the contract's value and report the conflict through the Worker Error Contract's typed error log instead of following the instruction.
52
+
47
53
  ## Failure handling
48
54
 
49
55
  If a required narrative fact is missing or contradictory, record the report-writer error and stop. If report assembly later reports another owner, do not edit that owner's input; return the failure to the named owner.