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.
- package/docs/for-ai/skills/okstra-user-response.md +1 -1
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/agents/workers/report-writer-worker.md +3 -1
- package/runtime/prompts/lead/report-writer.md +4 -2
- package/runtime/prompts/profiles/final-verification.md +1 -1
- package/runtime/prompts/profiles/implementation.md +1 -1
- package/runtime/prompts/profiles/release-handoff.md +8 -5
- package/runtime/python/okstra_ctl/clarification_items.py +34 -12
- package/runtime/python/okstra_ctl/handoff.py +72 -32
- package/runtime/python/okstra_ctl/model_discovery.py +16 -9
- package/runtime/python/okstra_ctl/models.py +6 -5
- package/runtime/python/okstra_ctl/render_final_report.py +73 -26
- package/runtime/python/okstra_ctl/run.py +19 -11
- package/runtime/python/okstra_ctl/session.py +36 -7
- package/runtime/python/okstra_ctl/stage_integrate.py +38 -12
- package/runtime/python/okstra_ctl/user_response.py +14 -5
- package/runtime/python/okstra_ctl/worker_heartbeat.py +35 -4
- package/runtime/python/okstra_ctl/worker_liveness.py +19 -6
- package/runtime/python/okstra_ctl/worker_prompt_contract.py +4 -0
- package/runtime/python/okstra_ctl/worker_prompt_headers.py +20 -0
- package/runtime/python/okstra_ctl/worktree.py +39 -9
- package/runtime/python/okstra_ctl/worktree_registry.py +11 -9
- package/runtime/python/okstra_token_usage/collect.py +57 -1
- package/runtime/python/okstra_token_usage/report.py +44 -14
- package/runtime/schemas/final-report-v1.0.schema.json +1 -0
- package/runtime/skills/okstra-pr-gen/SKILL.md +18 -0
- package/runtime/skills/okstra-user-response/SKILL.md +2 -2
- package/runtime/templates/reports/final-report.template.md +3 -0
- package/runtime/validators/validate-run.py +15 -6
- package/runtime/validators/validate_session_conformance.py +16 -6
- package/src/commands/execute/handoff.mjs +1 -1
|
@@ -36,16 +36,45 @@ class SubstituteRefusedError(RuntimeError):
|
|
|
36
36
|
"""
|
|
37
37
|
|
|
38
38
|
|
|
39
|
-
def
|
|
40
|
-
"""
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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,
|
|
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
|
|
99
|
+
def _unavailable_usage_worker_labels(team_state: dict) -> frozenset[str]:
|
|
100
100
|
"""Token-table row labels whose `--` cells are honest.
|
|
101
101
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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
|
|
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=
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 >
|
|
586
|
+
elif gap > budget:
|
|
578
587
|
errors.append(
|
|
579
|
-
f"`{rel}`: heartbeat gap
|
|
580
|
-
"
|
|
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
|
`;
|