okstra 0.132.0 → 0.134.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 (32) hide show
  1. package/docs/for-ai/skills/okstra-user-response.md +1 -1
  2. package/package.json +1 -1
  3. package/runtime/BUILD.json +2 -2
  4. package/runtime/agents/workers/report-writer-worker.md +3 -1
  5. package/runtime/prompts/lead/report-writer.md +4 -2
  6. package/runtime/prompts/profiles/final-verification.md +1 -1
  7. package/runtime/prompts/profiles/implementation.md +1 -1
  8. package/runtime/prompts/profiles/release-handoff.md +8 -5
  9. package/runtime/python/okstra_ctl/clarification_items.py +34 -12
  10. package/runtime/python/okstra_ctl/handoff.py +72 -32
  11. package/runtime/python/okstra_ctl/model_discovery.py +16 -9
  12. package/runtime/python/okstra_ctl/models.py +6 -5
  13. package/runtime/python/okstra_ctl/render_final_report.py +73 -26
  14. package/runtime/python/okstra_ctl/run.py +19 -11
  15. package/runtime/python/okstra_ctl/session.py +36 -7
  16. package/runtime/python/okstra_ctl/stage_integrate.py +38 -12
  17. package/runtime/python/okstra_ctl/user_response.py +14 -5
  18. package/runtime/python/okstra_ctl/worker_heartbeat.py +35 -4
  19. package/runtime/python/okstra_ctl/worker_liveness.py +19 -6
  20. package/runtime/python/okstra_ctl/worker_prompt_contract.py +4 -0
  21. package/runtime/python/okstra_ctl/worker_prompt_headers.py +20 -0
  22. package/runtime/python/okstra_ctl/worktree.py +39 -9
  23. package/runtime/python/okstra_ctl/worktree_registry.py +11 -9
  24. package/runtime/python/okstra_token_usage/collect.py +57 -1
  25. package/runtime/python/okstra_token_usage/report.py +44 -14
  26. package/runtime/schemas/final-report-v1.0.schema.json +1 -0
  27. package/runtime/skills/okstra-pr-gen/SKILL.md +18 -0
  28. package/runtime/skills/okstra-user-response/SKILL.md +2 -2
  29. package/runtime/templates/reports/final-report.template.md +3 -0
  30. package/runtime/validators/validate-run.py +15 -6
  31. package/runtime/validators/validate_session_conformance.py +16 -6
  32. package/src/commands/execute/handoff.mjs +1 -1
@@ -36,16 +36,45 @@ class SubstituteRefusedError(RuntimeError):
36
36
  """
37
37
 
38
38
 
39
- def _role_match(state_role: str, data_role: str) -> bool:
40
- """Loose match between team-state worker role and data.json
41
- executionStatus row role. team-state uses tags like ``claude-worker``
42
- or ``report-writer``; data.json uses human strings like ``Claude
43
- worker``. We normalise both to ``alnum-only-lowercase`` so the two
44
- line up across rephrasings.
39
+ def _norm(s: str) -> str:
40
+ """alnum-only lowercase, so role / agent / model line up across
41
+ rephrasings (``Claude worker`` vs ``claude``; ``gpt-5.6-sol`` vs
42
+ ``gpt56sol``)."""
43
+ return "".join(ch.lower() for ch in s if ch.isalnum())
44
+
45
+
46
+ def _match_worker_index(row: dict, workers: list, used: set[int]) -> int | None:
47
+ """Pick the team-state worker that owns this executionStatus row, never
48
+ reusing a worker an earlier row already claimed.
49
+
50
+ A contract-following report names each row after its team-state worker
51
+ (``Claude worker``), so role equality / containment matches. A report
52
+ that instead used function roles (``Analysis verifier``, ``Acceptance
53
+ critic``) falls back to a *unique* provider(agent)+model match. When two
54
+ rows share one provider aggregate (codex verifier + critic → one ``Codex
55
+ worker``), only the first is attributed; the second stays null because
56
+ team-state holds no separate figure for it.
45
57
  """
46
- def normalise(s: str) -> str:
47
- return "".join(ch.lower() for ch in s if ch.isalnum())
48
- return normalise(state_role) == normalise(data_role)
58
+ role = _norm(row.get("role", ""))
59
+ agent = _norm(row.get("agent", ""))
60
+ model = _norm(row.get("model", ""))
61
+ for i, worker in enumerate(workers):
62
+ if i in used:
63
+ continue
64
+ worker_role = _norm(worker.get("role", ""))
65
+ if worker_role and role and (
66
+ worker_role == role or worker_role in role or role in worker_role
67
+ ):
68
+ return i
69
+ candidates = [
70
+ i
71
+ for i, worker in enumerate(workers)
72
+ if i not in used
73
+ and _norm(worker.get("agent", ""))
74
+ and _norm(worker.get("agent", "")) in agent
75
+ and _norm(worker.get("model", "")) == model
76
+ ]
77
+ return candidates[0] if len(candidates) == 1 else None
49
78
 
50
79
 
51
80
  def _populate_execution_row(row: dict, source: dict) -> None:
@@ -179,17 +208,18 @@ def populate_data_token_cells(data_path: Path, team_state: dict) -> int:
179
208
  lead_source = {"usage": team_state.get("leadUsage") or {}}
180
209
  workers = team_state.get("workers") or []
181
210
  rows = data.get("executionStatus") or []
211
+ used_workers: set[int] = set()
182
212
  for row in rows:
183
213
  role = row.get("role", "")
184
214
  if "lead" in role.lower():
185
215
  _populate_execution_row(row, lead_source)
186
216
  changes += 1
187
217
  continue
188
- for worker in workers:
189
- if _role_match(worker.get("role", ""), role):
190
- _populate_execution_row(row, worker)
191
- changes += 1
192
- break
218
+ idx = _match_worker_index(row, workers, used_workers)
219
+ if idx is not None:
220
+ used_workers.add(idx)
221
+ _populate_execution_row(row, workers[idx])
222
+ changes += 1
193
223
 
194
224
  data_path.write_text(
195
225
  json.dumps(data, indent=2, ensure_ascii=False) + "\n",
@@ -524,6 +524,7 @@
524
524
  "additionalProperties": false,
525
525
  "properties": {
526
526
  "h1": { "enum": ["local checkout", "push + PR", "skip"] },
527
+ "h1c": { "type": "string" },
527
528
  "h2": { "type": "string" },
528
529
  "h2b": {
529
530
  "enum": ["not-run", "clean", "proceed anyway", "change base branch", "cancel"]
@@ -37,6 +37,24 @@ Present a 3-option picker (`AskUserQuestion`):
37
37
 
38
38
  ## Mode A — Generate PR
39
39
 
40
+ ### A0. Branch naming (allowlist)
41
+
42
+ Whenever this skill creates or proposes a head branch name, the prefix comes
43
+ from this list and nothing else:
44
+
45
+ | work kind | prefix |
46
+ | --- | --- |
47
+ | feature, improvement | `feature/` |
48
+ | bug fix | `fix/` |
49
+ | refactor | `refactor/` |
50
+ | ops, chore | `ops/` |
51
+ | unclassified | `task/` |
52
+
53
+ `feat/`, `bugfix/`, `chore/`, `hotfix/` are not in the allowlist — do not emit
54
+ them. This mirrors okstra's worktree branch namespace, whose SSOT is
55
+ `_WORK_CATEGORY_NAMESPACE` in `scripts/okstra_ctl/worktree.py`. A branch name
56
+ the user states explicitly wins verbatim; never rewrite it.
57
+
40
58
  ### A1. Pick a template
41
59
 
42
60
  ```bash
@@ -55,10 +55,10 @@ okstra user-response list --home <resolved-home> --project <projectId> --limit 3
55
55
  ```
56
56
 
57
57
  Returns a JSON array (latest report mtime first); each entry:
58
- `{taskKey, taskType, seq, reportPath, reportMtime, openApprovalCount, unreadable}`.
58
+ `{taskKey, taskType, seq, reportPath, reportMtime, openBlockerCount, openApprovalCount, unreadable}`. `openBlockerCount` counts every open row that still owes the user an answer (`Blocks` = `approval` or `next-phase`); `openApprovalCount` is the `approval`-only subset that gates the frontmatter `approved` flip.
59
59
 
60
60
  - Empty array → answer `No task has open clarification items.` and stop.
61
- - Otherwise present a **3-option picker**: the top recommendations from the list (each shown as `taskKey (taskType, N open items)`), and the **final option is always "Enter directly"** — where the user pastes a `reportPath` or a `task-key` directly (for a task not in the top-3 window).
61
+ - Otherwise present a **3-option picker**: the top recommendations from the list (each shown as `taskKey (taskType, <openBlockerCount> open items)`), and the **final option is always "Enter directly"** — where the user pastes a `reportPath` or a `task-key` directly (for a task not in the top-3 window).
62
62
  - An entry with `unreadable: true` means its `## 1. Clarification Items` heading exists but drifted from the expected format. Flag it as `⚠ §1 format drift — the CLI could not parse its items` and do not proceed on it until the report is regenerated; do not fabricate rows for it.
63
63
 
64
64
  Carry the chosen entry's `reportPath` and `taskKey` forward.
@@ -483,6 +483,9 @@ Carried-forward plan items retain their prior verdicts verbatim; each such item
483
483
  | {{ t("releaseHandoff.questionsTableHeader.questionId") }} | {{ t("releaseHandoff.questionsTableHeader.questionBody") }} | {{ t("releaseHandoff.questionsTableHeader.userResponse") }} | {{ t("releaseHandoff.questionsTableHeader.allowedOptions") }} |
484
484
  |---------|-----------|--------------------|--------------------|
485
485
  | H1 | {{ t("releaseHandoff.h1Body") }} | `{{ releaseHandoff.userSelections.h1 | mdcell }}` | `local checkout` / `push + PR` / `skip` |
486
+ {% if releaseHandoff.userSelections.h1c %}
487
+ | H1c | Local checkout target | `{{ releaseHandoff.userSelections.h1c | mdcell }}` | `whole-task branch` / `stage <N> stack branch` |
488
+ {% endif %}
486
489
  | H2 | {{ t("releaseHandoff.h2Body") }} | `{{ (releaseHandoff.userSelections.h2 or t("releaseHandoff.h2DefaultLabel")) | mdcell }}` | {{ t("releaseHandoff.h2OptionsLabel") }} |
487
490
  | H2b | Merge conflict probe | `{{ (releaseHandoff.userSelections.h2b or releaseHandoff.mergeConflictProbe.kind) | mdcell }}` | `not-run` / `clean` / `proceed anyway` / `change base branch` / `cancel` |
488
491
  | H3 | {{ t("releaseHandoff.h3Body") }} | `{{ releaseHandoff.userSelections.h3 | mdcell }}` | `use as-is` / `edit then proceed` / `cancel` |
@@ -96,19 +96,28 @@ def utc_now() -> str:
96
96
  _CLI_WRAPPER_AGENTS = frozenset({"codex", "antigravity"})
97
97
 
98
98
 
99
- def _cli_wrapper_worker_labels(team_state: dict) -> frozenset[str]:
99
+ def _unavailable_usage_worker_labels(team_state: dict) -> frozenset[str]:
100
100
  """Token-table row labels whose `--` cells are honest.
101
101
 
102
- Mirrors `scripts/okstra_token_usage/report.py` `_worker_detail_label`
103
- (`"{role} ({agent}, {status})"`) so the match is on the rendered label the
104
- scanner actually sees.
102
+ Two ways a row legitimately renders `--`: a CLI-wrapper provider (tokens
103
+ live inside its own CLI) and any worker whose collected
104
+ `usage.source` is `unavailable`. The latter is not a value a worker typed —
105
+ the renderer (`okstra_token_usage/report.py` `_worker_detail_row`) emits
106
+ null cells for it, and the collector records *why* in the same block. Gating
107
+ it made an in-process worker the collector could not attribute fail the whole
108
+ run (observed dev-10172: no subagent jsonl carried the report-writer's
109
+ agentName, so a completed run was reported as contract-violated).
110
+
111
+ Labels mirror `_worker_detail_label` (`"{role} ({agent}, {status})"`) so the
112
+ match is on the rendered label the scanner actually sees.
105
113
  """
106
114
  labels = set()
107
115
  for worker in team_state.get("workers") or []:
108
116
  if not isinstance(worker, dict):
109
117
  continue
110
118
  agent = str(worker.get("agent") or "").strip()
111
- if agent not in _CLI_WRAPPER_AGENTS:
119
+ usage = worker.get("usage") if isinstance(worker.get("usage"), dict) else {}
120
+ if agent not in _CLI_WRAPPER_AGENTS and usage.get("source") != "unavailable":
112
121
  continue
113
122
  role = str(worker.get("role") or worker.get("workerId") or "Worker").strip()
114
123
  status = str(worker.get("status") or "").strip()
@@ -5778,7 +5787,7 @@ def main() -> int:
5778
5787
  contract["required_agent_status_entries"],
5779
5788
  failures,
5780
5789
  allow_unavailable_token_usage=_session_accounting(team_state) == "artifact-only",
5781
- unavailable_ok_labels=_cli_wrapper_worker_labels(team_state),
5790
+ unavailable_ok_labels=_unavailable_usage_worker_labels(team_state),
5782
5791
  )
5783
5792
  validate_team_state_usage(team_state, failures)
5784
5793
 
@@ -36,7 +36,7 @@ for _ssot_dir in (_VALIDATORS_DIR.parent / "scripts", _VALIDATORS_DIR.parent / "
36
36
 
37
37
  from okstra_ctl.worker_heartbeat import ( # noqa: E402
38
38
  HEARTBEAT_LINE_RE,
39
- HEARTBEAT_MAX_GAP_SECONDS,
39
+ max_gap_seconds_after,
40
40
  )
41
41
  from okstra_ctl.wrapper_status import read_wrapper_status # noqa: E402
42
42
 
@@ -66,7 +66,9 @@ _PROGRESS_LINE_RE = re.compile(
66
66
  # heartbeat 라인 shape 과 cadence 예산은 okstra_ctl.worker_heartbeat 정본을 쓴다 —
67
67
  # `okstra worker-liveness` 가 run 도중 같은 판정을 내리므로 정의가 갈리면 안 된다.
68
68
  _HEARTBEAT_LINE_RE = HEARTBEAT_LINE_RE
69
- _HEARTBEAT_MAX_GAP_SECONDS = HEARTBEAT_MAX_GAP_SECONDS
69
+
70
+ # 장기 단계 중 append 되는 진행 라인 (claude-worker.md "Heartbeat").
71
+ _IN_STAGE_PREFIX = "in-stage:"
70
72
 
71
73
  # Phase 5/6 진입 전 lead 가 Read 해야 하는 implementation 프로파일 sidecar.
72
74
  # 절대 경로는 레이어(repo / runtime / 설치본)마다 다르지만 basename 은 동일하다.
@@ -546,7 +548,10 @@ def _check_heartbeat_sidecar(path: Path, worker_id: str, errors: list[str]) -> N
546
548
  )
547
549
  if worker_id == "report-writer":
548
550
  for stage, _raw_ts in entries:
549
- if stage not in _REPORT_WRITER_HEARTBEAT_STAGES:
551
+ # `in-stage:<stage>` 계약이 장기 단계에 요구하는 진행 라인이다 —
552
+ # allowlist 가 이를 배제하면 워커는 cadence 를 지킬 방법이 없다.
553
+ base = stage[len(_IN_STAGE_PREFIX):] if stage.startswith(_IN_STAGE_PREFIX) else stage
554
+ if base not in _REPORT_WRITER_HEARTBEAT_STAGES:
550
555
  errors.append(
551
556
  f"`{rel}`: heartbeat stage `{stage}` is not allowed for report-writer."
552
557
  )
@@ -560,6 +565,7 @@ def _check_heartbeat_sidecar(path: Path, worker_id: str, errors: list[str]) -> N
560
565
  "stage must append its own line (agents/workers/claude-worker.md 'Heartbeat')."
561
566
  )
562
567
  prev: datetime | None = None
568
+ prev_stage = ""
563
569
  for stage, raw_ts in entries:
564
570
  ts = _parse_iso(raw_ts)
565
571
  if ts is None:
@@ -569,18 +575,22 @@ def _check_heartbeat_sidecar(path: Path, worker_id: str, errors: list[str]) -> N
569
575
  )
570
576
  continue
571
577
  if prev is not None:
578
+ # 예산은 구간을 여는 단계에서 고른다 — 이 공백이 재는 것은 직전에
579
+ # 선언된 단계의 작업 시간이다.
580
+ budget = max_gap_seconds_after(prev_stage)
572
581
  gap = (ts - prev).total_seconds()
573
582
  if gap < 0:
574
583
  errors.append(
575
584
  f"`{rel}`: heartbeat timestamps regress at stage `{stage}` ({raw_ts})."
576
585
  )
577
- elif gap > _HEARTBEAT_MAX_GAP_SECONDS:
586
+ elif gap > budget:
578
587
  errors.append(
579
- f"`{rel}`: heartbeat gap before stage `{stage}` is {int(gap)}s "
580
- "the append cadence MUST NOT exceed 5 minutes (+60s grace); emit "
588
+ f"`{rel}`: heartbeat gap after stage `{prev_stage}` is {int(gap)}s "
589
+ f"(budget {budget}s) emit "
581
590
  "`- PROGRESS: in-stage:<stage> <ISO>` during long stages."
582
591
  )
583
592
  prev = ts
593
+ prev_stage = stage
584
594
 
585
595
 
586
596
  def _check_worker_liveness(
@@ -14,7 +14,7 @@ Usage:
14
14
  okstra handoff record-pr --plan-run-root <dir> --stages 2,3 \\
15
15
  --branch <b> --url <u>
16
16
  okstra handoff local-checkout --project-root <dir> --project-id <id> \\
17
- --task-group <g> --task-id <t>
17
+ --task-group <g> --task-id <t> [--stage <N>]
18
18
 
19
19
  Exit codes: 0 ok / 1 자격·전제 위반 / 2 stage 간 merge 충돌(conflicts 동봉)
20
20
  `;