okstra 0.223.2 → 0.225.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/dist/commands/execute/render-bundle.mjs +7 -5
- package/dist/commands/execute/render-bundle.mjs.map +1 -1
- package/docs/cli.md +10 -2
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/bin/lib/okstra/cli.sh +5 -1
- package/runtime/bin/lib/okstra/globals.sh +1 -0
- package/runtime/bin/lib/okstra/usage.sh +4 -1
- package/runtime/bin/okstra-provider-exec.py +32 -16
- package/runtime/bin/okstra.sh +1 -0
- package/runtime/prompts/coding-preflight/clean-code.md +8 -1
- package/runtime/prompts/duties/code-reviewer.json +5 -3
- package/runtime/prompts/wizard/prompts.ko.json +10 -0
- package/runtime/python/okstra_ctl/adapters/providers/claude/adapter.py +2 -0
- package/runtime/python/okstra_ctl/adapters/providers/codex/adapter.py +3 -1
- package/runtime/python/okstra_ctl/adapters/providers/grok/adapter.py +6 -0
- package/runtime/python/okstra_ctl/adapters/providers/zai/adapter.py +5 -1
- package/runtime/python/okstra_ctl/agent/prompt_cli/results.py +25 -0
- package/runtime/python/okstra_ctl/code_review_coverage.py +213 -0
- package/runtime/python/okstra_ctl/code_review_target.py +31 -2
- package/runtime/python/okstra_ctl/coverage_census.py +2 -2
- package/runtime/python/okstra_ctl/dispatch_core.py +15 -0
- package/runtime/python/okstra_ctl/domain/worker_exec.py +6 -0
- package/runtime/python/okstra_ctl/incremental_carry.py +10 -16
- package/runtime/python/okstra_ctl/phases/final_verification/profile.md +1 -1
- package/runtime/python/okstra_ctl/phases/implementation/instructions/_implementation-diff-review.md +1 -1
- package/runtime/python/okstra_ctl/phases/implementation/instructions/_implementation-verifier.md +2 -2
- package/runtime/python/okstra_ctl/phases/implementation_planning/authoring.py +164 -33
- package/runtime/python/okstra_ctl/phases/implementation_planning/instructions/plan-body-verification.md +27 -3
- package/runtime/python/okstra_ctl/phases/implementation_planning/plan_body.py +33 -0
- package/runtime/python/okstra_ctl/phases/implementation_planning/profile.md +1 -0
- package/runtime/python/okstra_ctl/phases/implementation_planning/round_completion.py +177 -0
- package/runtime/python/okstra_ctl/plan_item_evidence.py +237 -0
- package/runtime/python/okstra_ctl/plan_items.py +12 -9
- package/runtime/python/okstra_ctl/plan_review_context.py +82 -0
- package/runtime/python/okstra_ctl/render.py +2 -0
- package/runtime/python/okstra_ctl/run.py +23 -2
- package/runtime/python/okstra_ctl/team.py +12 -18
- package/runtime/python/okstra_ctl/verdict_blocks.py +17 -1
- package/runtime/python/okstra_ctl/wizard/__init__.py +1 -0
- package/runtime/python/okstra_ctl/wizard/confirmation.py +2 -0
- package/runtime/python/okstra_ctl/wizard/engine.py +26 -5
- package/runtime/python/okstra_ctl/wizard/ids.py +3 -0
- package/runtime/python/okstra_ctl/wizard/registry.py +13 -0
- package/runtime/python/okstra_ctl/wizard/render.py +1 -0
- package/runtime/python/okstra_ctl/wizard/state.py +8 -0
- package/runtime/python/okstra_ctl/wizard/steps_identity.py +99 -16
- package/runtime/python/okstra_ctl/wizard/steps_options.py +52 -1
- package/runtime/python/okstra_ctl/wizard/steps_plan.py +14 -0
- package/runtime/python/okstra_ctl/worker_dispatch.py +9 -0
- package/runtime/python/okstra_ctl/worker_request.py +12 -1
- package/runtime/schemas/execution-manifest-v2.schema.json +1 -0
- package/runtime/schemas/final-report-v2.0.schema.json +3 -1
- package/runtime/schemas/final-report-v3.0.schema.json +3 -1
- package/runtime/skills/_fragments/bash-invocation-rule.md +1 -1
- package/runtime/skills/_fragments/host-question-interface.md +1 -0
- package/runtime/skills/_fragments/host-runtime-selection.md +1 -0
- package/runtime/skills/_fragments/wizard-bash-invocation-rule.md +1 -1
- package/runtime/skills/_fragments/wizard-preflight.md +1 -1
- package/runtime/skills/_fragments/wizard-render-bundle.md +4 -4
- package/runtime/skills/okstra-brief-gen/SKILL.md +45 -47
- package/runtime/skills/okstra-brief-gen/references/reporter-confirmations.md +3 -3
- package/runtime/skills/okstra-brief-gen/references/tracker-recursion.md +1 -1
- package/runtime/skills/okstra-chat/SKILL.md +1 -1
- package/runtime/skills/okstra-code-review/SKILL.md +26 -15
- package/runtime/skills/okstra-code-review/references/census-rules.md +38 -2
- package/runtime/skills/okstra-code-review/references/review-calibration.md +39 -15
- package/runtime/skills/okstra-container-build/SKILL.md +6 -2
- package/runtime/skills/okstra-discussion/SKILL.md +6 -6
- package/runtime/skills/okstra-inspect/SKILL.md +5 -3
- package/runtime/skills/okstra-manager/SKILL.md +8 -4
- package/runtime/skills/okstra-memory/SKILL.md +1 -1
- package/runtime/skills/okstra-pr-gen/SKILL.md +10 -6
- package/runtime/skills/okstra-rollup/SKILL.md +6 -2
- package/runtime/skills/okstra-run/SKILL.md +8 -7
- package/runtime/skills/okstra-schedule-gen/SKILL.md +13 -6
- package/runtime/skills/okstra-setup/SKILL.md +8 -4
- package/runtime/skills/okstra-setup/references/project-config.md +3 -3
- package/runtime/skills/okstra-usage/SKILL.md +5 -1
- package/runtime/skills/okstra-user-response/SKILL.md +7 -3
|
@@ -35,6 +35,7 @@ from .dispatch_core import (
|
|
|
35
35
|
DispatchPlan,
|
|
36
36
|
await_dispatches,
|
|
37
37
|
dispatch_plan,
|
|
38
|
+
existing_dispatch_plan as _plan_for_existing,
|
|
38
39
|
)
|
|
39
40
|
from .dispatch_checkpoints import (
|
|
40
41
|
record_collect_checkpoints,
|
|
@@ -257,6 +258,11 @@ def _await(args) -> int:
|
|
|
257
258
|
timeout_seconds=args.timeout_seconds,
|
|
258
259
|
heartbeat_seconds=0 if args.json else args.heartbeat_seconds,
|
|
259
260
|
)
|
|
261
|
+
from .phases.implementation_planning.round_completion import complete_ready_plan_round
|
|
262
|
+
|
|
263
|
+
completion = complete_ready_plan_round(plan) if code == 0 else None
|
|
264
|
+
if completion and completion["status"] == "failed":
|
|
265
|
+
code = 2
|
|
260
266
|
if should_record:
|
|
261
267
|
exit_state = _load_json(plan.team_state_path, "team-state")
|
|
262
268
|
lines.extend(record_collect_checkpoints(
|
|
@@ -272,11 +278,16 @@ def _await(args) -> int:
|
|
|
272
278
|
if args.json:
|
|
273
279
|
payload = _await_payload(plan, code == 0)
|
|
274
280
|
payload["progressLines"] = lines
|
|
281
|
+
if completion:
|
|
282
|
+
payload["planVerification"] = completion
|
|
275
283
|
_print_json(payload)
|
|
276
284
|
else:
|
|
277
285
|
for line in lines:
|
|
278
286
|
print(line)
|
|
279
|
-
|
|
287
|
+
if completion:
|
|
288
|
+
print(json.dumps({"planVerification": completion}, ensure_ascii=False))
|
|
289
|
+
print("PLAN_VERIFICATION_FAILED" if completion and completion["status"] == "failed"
|
|
290
|
+
else "ALL_WORKERS_DONE" if code == 0 else "POLL_TIMEOUT")
|
|
280
291
|
return code
|
|
281
292
|
|
|
282
293
|
|
|
@@ -402,23 +413,6 @@ def _observe_lead_session_from_manifest(
|
|
|
402
413
|
observe_lead_session(project_root, team_state_path)
|
|
403
414
|
|
|
404
415
|
|
|
405
|
-
def _plan_for_existing(
|
|
406
|
-
project_root: Path, workspace_root: Path, run_manifest_path: Path, manifest: Mapping[str, Any]
|
|
407
|
-
) -> DispatchPlan:
|
|
408
|
-
root = project_root.resolve()
|
|
409
|
-
manifest_path = _resolve_project_path(root, str(run_manifest_path))
|
|
410
|
-
return DispatchPlan(
|
|
411
|
-
project_root=root,
|
|
412
|
-
workspace_root=workspace_root.resolve(),
|
|
413
|
-
manifest_path=manifest_path,
|
|
414
|
-
team_state_path=_resolve_project_path(root, _require_string(manifest, "teamStatePath")),
|
|
415
|
-
lead_events_path=_resolve_project_path(root, _require_string(manifest, "leadEventsPath")),
|
|
416
|
-
manifest=manifest,
|
|
417
|
-
jobs=(),
|
|
418
|
-
default_backend=_manifest_backend(manifest),
|
|
419
|
-
)
|
|
420
|
-
|
|
421
|
-
|
|
422
416
|
def _await_payload(plan: DispatchPlan, completed: bool) -> dict[str, Any]:
|
|
423
417
|
team_state = _load_json(plan.team_state_path, "team-state")
|
|
424
418
|
dispatches = [d for d in team_state.get("workerDispatches", []) if isinstance(d, dict)]
|
|
@@ -17,6 +17,7 @@ refusing them costs a dispatch cycle and yields no information. A bare
|
|
|
17
17
|
from __future__ import annotations
|
|
18
18
|
|
|
19
19
|
import re
|
|
20
|
+
import json
|
|
20
21
|
from dataclasses import dataclass
|
|
21
22
|
|
|
22
23
|
VERDICT_TOKENS = frozenset({
|
|
@@ -64,7 +65,7 @@ _ITEM_RE = re.compile(r"^###[ \t]+(?P<id>[^\s:]+)[ \t]*:?.*$", re.MULTILINE)
|
|
|
64
65
|
# `_field_match` enforces that an opening `**` is closed exactly once and that
|
|
65
66
|
# a label without bold carries a bullet.
|
|
66
67
|
_FIELD_KEYS = ("Verdict", "Fixability", "Note", "Explanation", "Prior dissent",
|
|
67
|
-
"Basis", "Your evidence")
|
|
68
|
+
"Basis", "Your evidence", "Dependencies")
|
|
68
69
|
_CANONICAL_KEYS = {key.lower(): key for key in _FIELD_KEYS}
|
|
69
70
|
_FIELD_RE = re.compile(
|
|
70
71
|
r"^(?P<bullet>[-*+][ \t]+)?(?P<open>\*\*)?"
|
|
@@ -234,6 +235,7 @@ class VerdictBlock:
|
|
|
234
235
|
note: str
|
|
235
236
|
explanation: str
|
|
236
237
|
prior_dissent: str
|
|
238
|
+
dependencies: dict | None = None
|
|
237
239
|
|
|
238
240
|
|
|
239
241
|
def parse_verdict_blocks(text: str) -> dict[str, VerdictBlock]:
|
|
@@ -269,6 +271,19 @@ def _block(item_id: str, fields: dict[str, str]) -> VerdictBlock:
|
|
|
269
271
|
f"`{fixability or '(missing)'}` — expected one of "
|
|
270
272
|
f"{sorted(FIXABILITY_VALUES)}"
|
|
271
273
|
)
|
|
274
|
+
dependencies = None
|
|
275
|
+
if fields.get("Dependencies"):
|
|
276
|
+
try:
|
|
277
|
+
candidate = json.loads(fields["Dependencies"])
|
|
278
|
+
if (isinstance(candidate, dict) and isinstance(candidate.get("complete"), bool)
|
|
279
|
+
and all(isinstance(candidate.get(key), list)
|
|
280
|
+
and all(isinstance(value, str) and value for value in candidate[key])
|
|
281
|
+
for key in ("itemIds", "paths"))):
|
|
282
|
+
dependencies = {key: candidate[key] for key in ("complete", "itemIds", "paths")}
|
|
283
|
+
else:
|
|
284
|
+
dependencies = {"complete": False, "itemIds": [], "paths": []}
|
|
285
|
+
except (ValueError, TypeError):
|
|
286
|
+
dependencies = {"complete": False, "itemIds": [], "paths": []}
|
|
272
287
|
return VerdictBlock(
|
|
273
288
|
item_id=item_id,
|
|
274
289
|
verdict=token,
|
|
@@ -277,4 +292,5 @@ def _block(item_id: str, fields: dict[str, str]) -> VerdictBlock:
|
|
|
277
292
|
note=fields.get("Note", ""),
|
|
278
293
|
explanation=fields.get("Explanation", ""),
|
|
279
294
|
prior_dissent=fields.get("Prior dissent", ""),
|
|
295
|
+
dependencies=dependencies,
|
|
280
296
|
)
|
|
@@ -333,6 +333,8 @@ def confirmation_block(state: WizardState) -> str:
|
|
|
333
333
|
lines.append(f" pr-template : {state.pr_template_path} ({state.pr_template_scope or 'once'})")
|
|
334
334
|
if state.fix_cycle:
|
|
335
335
|
lines.append(f" fix-cycle : {state.fix_cycle}")
|
|
336
|
+
if state.reasoning_effort:
|
|
337
|
+
lines.append(f" reasoning : {state.reasoning_effort}")
|
|
336
338
|
lines.append(_msg(
|
|
337
339
|
state.workspace_root, "confirmation", "provider_data_scope",
|
|
338
340
|
project_root=state.project_root,
|
|
@@ -123,20 +123,41 @@ def _apply_answer(state: WizardState, prompt: Prompt, value: str) -> str:
|
|
|
123
123
|
return echo or ""
|
|
124
124
|
|
|
125
125
|
|
|
126
|
+
def _automatic_answers(state: WizardState, prompt: Prompt) -> list[tuple[Prompt, str]]:
|
|
127
|
+
"""Answers the wizard takes without showing the screen.
|
|
128
|
+
|
|
129
|
+
A one-line pick takes its line. A pick whose builder set `auto_value` takes it
|
|
130
|
+
unless the user came back to that step through Edit; a group takes each such
|
|
131
|
+
member and shows the rest.
|
|
132
|
+
"""
|
|
133
|
+
if _is_lone_choice_pick(prompt):
|
|
134
|
+
return [(prompt, prompt.options[0].value)]
|
|
135
|
+
members = prompt.questions if prompt.kind == "pick_group" else [prompt]
|
|
136
|
+
return [
|
|
137
|
+
(member, member.auto_value)
|
|
138
|
+
for member in members
|
|
139
|
+
if member.kind == "pick"
|
|
140
|
+
and member.auto_value
|
|
141
|
+
and member.step not in state.manual_steps
|
|
142
|
+
]
|
|
143
|
+
|
|
144
|
+
|
|
126
145
|
def next_prompt(state: WizardState) -> Prompt:
|
|
127
|
-
#
|
|
128
|
-
#
|
|
146
|
+
# 자동으로 답한 화면 자리에 다음 화면이 온다. 그것도 자동일 수 있어 반복하되,
|
|
147
|
+
# 같은 step 이 다시 오면(반복 가능한 step) 묻는다.
|
|
129
148
|
taken: set[str] = set()
|
|
130
149
|
while True:
|
|
131
150
|
prompt = _next_prompt_screen(state)
|
|
132
|
-
if prompt.step in taken
|
|
151
|
+
answers = [] if prompt.step in taken else _automatic_answers(state, prompt)
|
|
152
|
+
if not answers:
|
|
133
153
|
return _native_picker_screen(state, prompt)
|
|
134
154
|
taken.add(prompt.step)
|
|
135
155
|
attempt = copy.deepcopy(state)
|
|
136
156
|
try:
|
|
137
|
-
|
|
157
|
+
for member, value in answers:
|
|
158
|
+
_apply_answer(attempt, member, value)
|
|
138
159
|
except WizardError:
|
|
139
|
-
#
|
|
160
|
+
# 자동 답이 거절되면 사용자에게 보여 거절 사유를 받게 한다.
|
|
140
161
|
return _native_picker_screen(state, prompt)
|
|
141
162
|
state.__dict__.update(attempt.__dict__)
|
|
142
163
|
|
|
@@ -58,6 +58,7 @@ from .ids import (
|
|
|
58
58
|
S_PROJECT_EVIDENCE_PICK,
|
|
59
59
|
S_PR_TEMPLATE,
|
|
60
60
|
S_PR_TEMPLATE_PICK,
|
|
61
|
+
S_REASONING_EFFORT,
|
|
61
62
|
S_PR_TEMPLATE_SCOPE,
|
|
62
63
|
S_RELATED_TASKS,
|
|
63
64
|
S_RELATED_TASKS_PICK,
|
|
@@ -169,6 +170,9 @@ from .steps_plan import (
|
|
|
169
170
|
_submit_stage_pick,
|
|
170
171
|
)
|
|
171
172
|
from .steps_options import (
|
|
173
|
+
_build_reasoning_effort,
|
|
174
|
+
_reasoning_effort_required,
|
|
175
|
+
_submit_reasoning_effort,
|
|
172
176
|
_report_language_required,
|
|
173
177
|
_build_report_language,
|
|
174
178
|
_submit_report_language,
|
|
@@ -243,6 +247,8 @@ def _submit_edit_target(state: WizardState, value: str) -> Optional[str]:
|
|
|
243
247
|
_reset_from(state, value)
|
|
244
248
|
else:
|
|
245
249
|
raise WizardError(f"unknown step: {value!r}")
|
|
250
|
+
if value not in state.manual_steps:
|
|
251
|
+
state.manual_steps.append(value)
|
|
246
252
|
state.edit_target = value
|
|
247
253
|
state.confirmed = None
|
|
248
254
|
state.edit_target = ""
|
|
@@ -514,6 +520,13 @@ STEPS: list[Step] = [
|
|
|
514
520
|
and S_DIRECTIVE not in s.answered),
|
|
515
521
|
build=_build_directive, submit=_submit_directive,
|
|
516
522
|
owns=("directive", "directive_pending_text")),
|
|
523
|
+
# 역할 화면과 directive 뒤 — 이 run 이 띄울 워커가 정해진 다음에 묻는다.
|
|
524
|
+
Step(S_REASONING_EFFORT,
|
|
525
|
+
applies=lambda s: (S_REASONING_EFFORT not in s.answered
|
|
526
|
+
and not s.directive_pending_text
|
|
527
|
+
and _reasoning_effort_required(s)),
|
|
528
|
+
build=_build_reasoning_effort, submit=_submit_reasoning_effort,
|
|
529
|
+
owns=("reasoning_effort",)),
|
|
517
530
|
Step(S_RELATED_TASKS_PICK,
|
|
518
531
|
applies=lambda s: (_identity_ready(s)
|
|
519
532
|
and S_RELATED_TASKS_PICK not in s.answered),
|
|
@@ -118,6 +118,7 @@ def render_args(state: WizardState) -> dict[str, Any]:
|
|
|
118
118
|
),
|
|
119
119
|
"pr-template-path": pr_template,
|
|
120
120
|
"fix-cycle": state.fix_cycle,
|
|
121
|
+
"reasoning-effort": state.reasoning_effort,
|
|
121
122
|
"host-session-context-json": serialize_host_session_context(
|
|
122
123
|
_host_session_context(state)
|
|
123
124
|
),
|
|
@@ -132,6 +132,8 @@ class WizardState:
|
|
|
132
132
|
# confirm / edit
|
|
133
133
|
# "" | "yes" | "no" — done(release-handoff) task 재진입의 fix-cycle 기록 여부
|
|
134
134
|
fix_cycle: str = ""
|
|
135
|
+
# `REASONING_EFFORTS` 중 하나. 워커가 정해지기 전에는 비어 있다.
|
|
136
|
+
reasoning_effort: str = ""
|
|
135
137
|
confirmed: Optional[bool] = None
|
|
136
138
|
confirmation_stages: str = ""
|
|
137
139
|
confirmation_prompt: str = ""
|
|
@@ -143,6 +145,8 @@ class WizardState:
|
|
|
143
145
|
|
|
144
146
|
# bookkeeping
|
|
145
147
|
answered: list[str] = field(default_factory=list)
|
|
148
|
+
# Steps the user came back to through Edit; their auto_value no longer applies.
|
|
149
|
+
manual_steps: list[str] = field(default_factory=list)
|
|
146
150
|
|
|
147
151
|
def to_json(self) -> dict[str, Any]:
|
|
148
152
|
payload = asdict(self)
|
|
@@ -181,6 +185,9 @@ class Prompt:
|
|
|
181
185
|
multi: bool = False # only meaningful when kind == "pick"
|
|
182
186
|
# only meaningful when kind == "pick_group": one entry per AskUserQuestion tab
|
|
183
187
|
questions: list["Prompt"] = field(default_factory=list)
|
|
188
|
+
# The answer the wizard takes without asking, because it comes from a recorded
|
|
189
|
+
# source; the confirm screen shows it and its Edit menu asks for it again.
|
|
190
|
+
auto_value: str = ""
|
|
184
191
|
|
|
185
192
|
def __post_init__(self) -> None:
|
|
186
193
|
"""자유 입력 선택지는 언제나 마지막이다.
|
|
@@ -622,6 +629,7 @@ _FIELD_DEFAULTS: dict[str, Any] = {
|
|
|
622
629
|
"pr_template_path": "", "pr_template_pending_text": False,
|
|
623
630
|
"pr_template_scope": "",
|
|
624
631
|
"fix_cycle": "",
|
|
632
|
+
"reasoning_effort": "",
|
|
625
633
|
"confirmed": None, "edit_target": "", "confirmation_prompt": "", "confirmation_stages": "",
|
|
626
634
|
"confirmation_scope": {}, "user_authorization": {},
|
|
627
635
|
}
|
|
@@ -6,12 +6,15 @@ from pathlib import Path
|
|
|
6
6
|
from collections.abc import Mapping
|
|
7
7
|
from typing import Any, Optional
|
|
8
8
|
|
|
9
|
-
from okstra_ctl import next_phase
|
|
9
|
+
from okstra_ctl import next_phase, worktree_registry
|
|
10
|
+
from okstra_ctl.consumers import latest_done_by_stage, read_consumers
|
|
10
11
|
from okstra_ctl.ids import slugify_task_segment
|
|
12
|
+
from okstra_ctl.paths import RunRef
|
|
11
13
|
from okstra_ctl.group_context import (
|
|
12
14
|
QUEUE_DONE,
|
|
13
15
|
QUEUE_IN_PROGRESS,
|
|
14
16
|
QUEUE_NOT_STARTED,
|
|
17
|
+
group_briefs,
|
|
15
18
|
group_context_file,
|
|
16
19
|
group_queue,
|
|
17
20
|
is_group_context_file,
|
|
@@ -79,15 +82,18 @@ def _validate_base_ref(ref: str, project_root: Path) -> str:
|
|
|
79
82
|
ref = (ref or "").strip()
|
|
80
83
|
if not ref:
|
|
81
84
|
raise WizardError("base ref is empty")
|
|
82
|
-
|
|
85
|
+
if not _ref_resolves(_git_main_worktree(project_root), ref):
|
|
86
|
+
raise WizardError(f"base ref not found in repository: {ref}")
|
|
87
|
+
return ref
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _ref_resolves(main_wt: Path, ref: str) -> bool:
|
|
83
91
|
rc = subprocess.call(
|
|
84
92
|
["git", "-C", str(main_wt), "rev-parse",
|
|
85
93
|
"--verify", "--quiet", f"{ref}^{{commit}}"],
|
|
86
94
|
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
|
87
95
|
)
|
|
88
|
-
|
|
89
|
-
raise WizardError(f"base ref not found in repository: {ref}")
|
|
90
|
-
return ref
|
|
96
|
+
return rc == 0
|
|
91
97
|
|
|
92
98
|
|
|
93
99
|
# --- builders ---
|
|
@@ -605,8 +611,12 @@ def _build_task_type(state: WizardState) -> Prompt:
|
|
|
605
611
|
brief_phase = _brief_recommended_task_type(state) if fresh_task else ""
|
|
606
612
|
recommended = (revision_requested or state.task_type
|
|
607
613
|
or _recommended_task_type(state, manifest))
|
|
614
|
+
# Only a recommendation okstra recorded is taken without asking: the pointer,
|
|
615
|
+
# a revision request, or the brief's own entry phase.
|
|
616
|
+
auto_value = recommended
|
|
608
617
|
if not recommended and not workflow:
|
|
609
618
|
recommended = state.requested_task_type or brief_phase or TASK_TYPE_VALUES[0]
|
|
619
|
+
auto_value = recommended if recommended in (state.requested_task_type, brief_phase) else ""
|
|
610
620
|
if brief_phase and recommended == brief_phase:
|
|
611
621
|
recommended_suffix = t["options"].get("_BRIEF_SUFFIX", recommended_suffix)
|
|
612
622
|
pointer = next_phase.promote(workflow.get("nextRecommendedPhase"))
|
|
@@ -638,7 +648,8 @@ def _build_task_type(state: WizardState) -> Prompt:
|
|
|
638
648
|
options.append(_opt(PICK_TYPE_CUSTOM, t["options"][PICK_TYPE_CUSTOM]))
|
|
639
649
|
return Prompt(step=S_TASK_TYPE, kind="pick",
|
|
640
650
|
label=t["label"], options=options,
|
|
641
|
-
echo_template=t["echo_template"]
|
|
651
|
+
echo_template=t["echo_template"],
|
|
652
|
+
auto_value=auto_value)
|
|
642
653
|
|
|
643
654
|
|
|
644
655
|
def _carry_in_existing_brief(state: WizardState) -> str:
|
|
@@ -835,28 +846,100 @@ def _build_brief_carry(state: WizardState) -> Prompt:
|
|
|
835
846
|
)
|
|
836
847
|
|
|
837
848
|
|
|
838
|
-
def
|
|
839
|
-
"""The main worktree's branch when
|
|
849
|
+
def _checked_out_branch(main_wt: Path) -> str:
|
|
850
|
+
"""The main worktree's branch, or '' when HEAD is detached.
|
|
840
851
|
|
|
841
852
|
Projects differ in their integration branch (main vs preprod), and nothing in
|
|
842
853
|
okstra records it; the branch the user keeps checked out is the observed hint.
|
|
843
854
|
"""
|
|
844
|
-
try:
|
|
845
|
-
main_wt = _git_main_worktree(project_root)
|
|
846
|
-
except WizardError:
|
|
847
|
-
return ""
|
|
848
855
|
result = subprocess.run(
|
|
849
856
|
["git", "-C", str(main_wt), "symbolic-ref", "--quiet", "--short", "HEAD"],
|
|
850
857
|
capture_output=True, text=True,
|
|
851
858
|
)
|
|
852
|
-
|
|
853
|
-
|
|
859
|
+
return result.stdout.strip() if result.returncode == 0 else ""
|
|
860
|
+
|
|
861
|
+
|
|
862
|
+
def _recorded_stage_branches(state: WizardState, task_id: str) -> list[str]:
|
|
863
|
+
"""A task's stage branches, top of the stack first: PR heads, then done stages.
|
|
864
|
+
|
|
865
|
+
release-handoff `record-pr` writes a `pr` row per stage PR; a task that never
|
|
866
|
+
reached handoff only has `done` rows, whose branch lives in the worktree
|
|
867
|
+
registry's stage row (kept after the stage is released).
|
|
868
|
+
"""
|
|
869
|
+
key = f"{state.project_id}:{state.task_group}:{task_id}"
|
|
870
|
+
task_root = find_task_root(Path(state.project_root), key)
|
|
871
|
+
if task_root is None:
|
|
872
|
+
return []
|
|
873
|
+
rows = read_consumers(RunRef.from_task_root(task_root, "implementation-planning").run_dir)
|
|
874
|
+
prs = [row for row in rows if row.get("status") == "pr" and row.get("branch")]
|
|
875
|
+
prs.sort(key=lambda row: max(row.get("stages") or [0]), reverse=True)
|
|
876
|
+
branches = [str(row["branch"]) for row in prs]
|
|
877
|
+
for stage in sorted(latest_done_by_stage(rows), reverse=True):
|
|
878
|
+
stage_row = worktree_registry.get_stage_row(
|
|
879
|
+
state.project_id, state.task_group, task_id, stage) or {}
|
|
880
|
+
if stage_row.get("branch"):
|
|
881
|
+
branches.append(str(stage_row["branch"]))
|
|
882
|
+
return branches
|
|
883
|
+
|
|
884
|
+
|
|
885
|
+
def _predecessor_base_ref(state: WizardState, main_wt: Path) -> tuple[str, bool]:
|
|
886
|
+
"""(branch, succession) from the nearest earlier task in the group's start order.
|
|
887
|
+
|
|
888
|
+
A follow-up task in a group usually builds on the previous task's unmerged
|
|
889
|
+
stage branches. `succession` is True when okstra records that this task
|
|
890
|
+
follows that one: it is the next row in the start order (what the
|
|
891
|
+
predecessor's closeout named as `nextInGroup`), or this task's brief waits
|
|
892
|
+
for that ticket in its Related Task Graph.
|
|
893
|
+
"""
|
|
894
|
+
if not (state.project_root and state.task_group and state.task_id):
|
|
895
|
+
return "", False
|
|
896
|
+
project_root = Path(state.project_root)
|
|
897
|
+
queue = _group_start_order(project_root, state.task_group)
|
|
898
|
+
brief = _project_relative_path(Path(state.brief_path), project_root) if state.brief_path else ""
|
|
899
|
+
current = next(
|
|
900
|
+
(index for index, row in enumerate(queue)
|
|
901
|
+
if row.task_id == state.task_id or (brief and row.brief == brief)),
|
|
902
|
+
None,
|
|
903
|
+
)
|
|
904
|
+
if current is None:
|
|
905
|
+
return "", False
|
|
906
|
+
waits: set[str] = set()
|
|
907
|
+
for item in group_briefs(project_root, state.task_group):
|
|
908
|
+
waits.update(item["waits_for"].get(queue[current].ticket_id, set()))
|
|
909
|
+
for index in range(current - 1, -1, -1):
|
|
910
|
+
row = queue[index]
|
|
911
|
+
branch = next(
|
|
912
|
+
(b for b in _recorded_stage_branches(state, row.task_id) if _ref_resolves(main_wt, b)),
|
|
913
|
+
"",
|
|
914
|
+
)
|
|
915
|
+
if branch:
|
|
916
|
+
return branch, index == current - 1 or row.ticket_id in waits
|
|
917
|
+
return "", False
|
|
918
|
+
|
|
919
|
+
|
|
920
|
+
def _base_ref_candidates(state: WizardState) -> tuple[list[str], str]:
|
|
921
|
+
"""(refs in display order, recommended ref or '')."""
|
|
922
|
+
try:
|
|
923
|
+
main_wt = _git_main_worktree(Path(state.project_root))
|
|
924
|
+
except WizardError:
|
|
925
|
+
return list(CANONICAL_BASE_REFS), ""
|
|
926
|
+
predecessor, succession = _predecessor_base_ref(state, main_wt)
|
|
927
|
+
checked_out = _checked_out_branch(main_wt)
|
|
928
|
+
derived = [predecessor] if predecessor else []
|
|
929
|
+
if (checked_out and checked_out not in CANONICAL_BASE_REFS
|
|
930
|
+
and checked_out not in derived and _ref_resolves(main_wt, checked_out)):
|
|
931
|
+
derived.append(checked_out)
|
|
932
|
+
if succession:
|
|
933
|
+
recommended = predecessor
|
|
934
|
+
else:
|
|
935
|
+
recommended = checked_out if checked_out in CANONICAL_BASE_REFS else ""
|
|
936
|
+
refs = derived + [r for r in CANONICAL_BASE_REFS if r not in derived]
|
|
937
|
+
return sorted(refs, key=lambda r: r != recommended), recommended
|
|
854
938
|
|
|
855
939
|
|
|
856
940
|
def _build_base_ref_pick(state: WizardState) -> Prompt:
|
|
857
941
|
t = _p(state.workspace_root, "base_ref_pick")
|
|
858
|
-
recommended =
|
|
859
|
-
refs = sorted(CANONICAL_BASE_REFS, key=lambda r: r != recommended)
|
|
942
|
+
refs, recommended = _base_ref_candidates(state)
|
|
860
943
|
options = [_opt(r, recommended=r == recommended) for r in refs]
|
|
861
944
|
for value, label in _static_options(t):
|
|
862
945
|
options.append(_opt(value=value, label=label))
|
|
@@ -7,6 +7,7 @@ from pathlib import Path
|
|
|
7
7
|
from typing import Callable, Optional
|
|
8
8
|
|
|
9
9
|
from okstra_ctl.clarification_items import user_response_sidecars
|
|
10
|
+
from okstra_ctl.domain.worker_exec import REASONING_EFFORTS
|
|
10
11
|
from okstra_ctl.manager_paths import MANAGER_CONTEXT_DIRECTIVE_PREFIX
|
|
11
12
|
from okstra_ctl.json_boundary import JsonBoundaryError, load_owned_object, write_owned_object_atomic
|
|
12
13
|
from okstra_ctl.report_language import is_language_tag
|
|
@@ -25,6 +26,7 @@ from .ids import (
|
|
|
25
26
|
S_PR_TEMPLATE,
|
|
26
27
|
S_PR_TEMPLATE_PICK,
|
|
27
28
|
S_PR_TEMPLATE_SCOPE,
|
|
29
|
+
S_REASONING_EFFORT,
|
|
28
30
|
S_RELATED_TASKS,
|
|
29
31
|
S_RELATED_TASKS_PICK,
|
|
30
32
|
S_REPORT_LANGUAGE,
|
|
@@ -33,7 +35,14 @@ from .ids import (
|
|
|
33
35
|
_REUSE_LAST_TOKEN,
|
|
34
36
|
_SIBLINGS_TOKEN,
|
|
35
37
|
)
|
|
36
|
-
from .state import
|
|
38
|
+
from .state import (
|
|
39
|
+
Option,
|
|
40
|
+
Prompt,
|
|
41
|
+
WizardError,
|
|
42
|
+
WizardState,
|
|
43
|
+
_identity_ready,
|
|
44
|
+
_load_role_profile_for_state,
|
|
45
|
+
)
|
|
37
46
|
from .prompts import _opt, _p, _pick_snippet
|
|
38
47
|
from .sources import _latest_run_inputs, _require_file, _suggest_latest_final_report
|
|
39
48
|
|
|
@@ -120,6 +129,9 @@ class _OptionalCachedPickSpec:
|
|
|
120
129
|
# 추천 옵션 라벨·echo 뒤에 덧붙일 동적 안내(예: 함께 첨부될 사이드카 개수).
|
|
121
130
|
# (state, 추천 relpath) -> note. 미설정 picker 는 영향 없음.
|
|
122
131
|
recommend_note: Optional[Callable[["WizardState", str], str]] = None
|
|
132
|
+
# Whether the wizard takes the suggestion without asking. A previous directive
|
|
133
|
+
# is an instruction written for another run, so reusing it stays a question.
|
|
134
|
+
auto_take_suggestion: bool = True
|
|
123
135
|
|
|
124
136
|
|
|
125
137
|
def _build_optional_cached_pick(state: WizardState, spec: _OptionalCachedPickSpec) -> Prompt:
|
|
@@ -137,10 +149,17 @@ def _build_optional_cached_pick(state: WizardState, spec: _OptionalCachedPickSpe
|
|
|
137
149
|
setattr(state, spec.cache_attr, suggestion)
|
|
138
150
|
options.append(_opt(PICK_SKIP, t["options"][PICK_SKIP]))
|
|
139
151
|
options.append(_opt(PICK_TYPE_CUSTOM, t["options"][PICK_TYPE_CUSTOM]))
|
|
152
|
+
if not suggestion:
|
|
153
|
+
auto_value = PICK_SKIP
|
|
154
|
+
elif spec.auto_take_suggestion:
|
|
155
|
+
auto_value = spec.recommend_token
|
|
156
|
+
else:
|
|
157
|
+
auto_value = ""
|
|
140
158
|
return Prompt(
|
|
141
159
|
step=spec.step, kind="pick",
|
|
142
160
|
label=t["label"], options=options,
|
|
143
161
|
echo_template=t["echo_template"],
|
|
162
|
+
auto_value=auto_value,
|
|
144
163
|
)
|
|
145
164
|
|
|
146
165
|
|
|
@@ -199,6 +218,7 @@ _DIRECTIVE_PICK_SPEC = _OptionalCachedPickSpec(
|
|
|
199
218
|
cache_attr="last_directive_cached", target_attr="directive",
|
|
200
219
|
pending_attr="directive_pending_text",
|
|
201
220
|
recommend_note=_directive_context_note,
|
|
221
|
+
auto_take_suggestion=False,
|
|
202
222
|
)
|
|
203
223
|
|
|
204
224
|
|
|
@@ -417,3 +437,34 @@ def _submit_pr_template_scope(state: WizardState, value: str) -> Optional[str]:
|
|
|
417
437
|
)
|
|
418
438
|
state.pr_template_scope = value
|
|
419
439
|
return f"pr-template-scope: {value}"
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
_RECOMMENDED_REASONING_EFFORT = "high"
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
def _reasoning_effort_required(state: WizardState) -> bool:
|
|
446
|
+
"""워커를 띄우지 않는 프로필(release-handoff)에는 강도를 넘길 CLI 가 없다."""
|
|
447
|
+
return _identity_ready(state) and bool(_load_role_profile_for_state(state).roles)
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
def _build_reasoning_effort(state: WizardState) -> Prompt:
|
|
451
|
+
t = _p(state.workspace_root, "reasoning_effort")
|
|
452
|
+
others = [level for level in REASONING_EFFORTS if level != _RECOMMENDED_REASONING_EFFORT]
|
|
453
|
+
return Prompt(
|
|
454
|
+
step=S_REASONING_EFFORT, kind="pick", label=t["label"],
|
|
455
|
+
options=[
|
|
456
|
+
_opt(_RECOMMENDED_REASONING_EFFORT,
|
|
457
|
+
t["options"][_RECOMMENDED_REASONING_EFFORT], recommended=True),
|
|
458
|
+
*(_opt(level, t["options"][level]) for level in others),
|
|
459
|
+
],
|
|
460
|
+
echo_template=t["echo_template"])
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
def _submit_reasoning_effort(state: WizardState, value: str) -> Optional[str]:
|
|
464
|
+
level = value.strip().lower()
|
|
465
|
+
if level not in REASONING_EFFORTS:
|
|
466
|
+
raise WizardError(
|
|
467
|
+
f"expected one of {', '.join(REASONING_EFFORTS)}, got: {value!r}"
|
|
468
|
+
)
|
|
469
|
+
state.reasoning_effort = level
|
|
470
|
+
return f"reasoning-effort: {level}"
|
|
@@ -276,6 +276,7 @@ def _build_approved_plan_pick(state: WizardState) -> Prompt:
|
|
|
276
276
|
step=S_APPROVED_PLAN_PICK, kind="pick",
|
|
277
277
|
label=label, options=options,
|
|
278
278
|
echo_template=t["echo_template"],
|
|
279
|
+
auto_value=PICK_USE_DEFAULT if default is not None else "",
|
|
279
280
|
)
|
|
280
281
|
|
|
281
282
|
|
|
@@ -341,9 +342,22 @@ def _build_approve_plan_confirm(state: WizardState) -> Prompt:
|
|
|
341
342
|
for k, v in options_map.items()
|
|
342
343
|
],
|
|
343
344
|
echo_template=t["echo_template"],
|
|
345
|
+
auto_value="yes" if _already_approved(state) else "",
|
|
344
346
|
)
|
|
345
347
|
|
|
346
348
|
|
|
349
|
+
def _already_approved(state: WizardState) -> bool:
|
|
350
|
+
"""An approved plan with no exported approval sidecar leaves nothing to decide."""
|
|
351
|
+
if state.html_approval_sidecar or not state.approve_plan_candidate:
|
|
352
|
+
return False
|
|
353
|
+
try:
|
|
354
|
+
_, fully_approved = _classify_approved_plan(
|
|
355
|
+
state.approve_plan_candidate, Path(state.project_root))
|
|
356
|
+
except (PrepareError, WizardError):
|
|
357
|
+
return False
|
|
358
|
+
return fully_approved
|
|
359
|
+
|
|
360
|
+
|
|
347
361
|
def _submit_approve_plan_confirm(state: WizardState, value: str) -> Optional[str]:
|
|
348
362
|
allowed = ("yes_apply", "yes", "no") if state.html_approval_sidecar else ("yes", "no")
|
|
349
363
|
if value not in allowed:
|
|
@@ -45,10 +45,19 @@ def main(argv: Sequence[str] | None = None) -> int:
|
|
|
45
45
|
_print_json(_backend_payload(backend_plan, dry_run=True))
|
|
46
46
|
return 0
|
|
47
47
|
code, lines = _dispatch_recording_checkpoints(backend_plan)
|
|
48
|
+
from .phases.implementation_planning.round_completion import complete_ready_plan_round
|
|
49
|
+
from .worker_prompt_policy import is_plan_verify_dispatch_kind
|
|
50
|
+
|
|
51
|
+
completion = complete_ready_plan_round(backend_plan) if code == 0 and any(
|
|
52
|
+
is_plan_verify_dispatch_kind(job.dispatch_kind) for job in backend_plan.jobs
|
|
53
|
+
) else None
|
|
54
|
+
if completion and completion["status"] == "failed":
|
|
55
|
+
code = 2
|
|
48
56
|
_print_json({
|
|
49
57
|
**_backend_payload(backend_plan, dry_run=False),
|
|
50
58
|
"exitCode": code,
|
|
51
59
|
"progressLines": lines,
|
|
60
|
+
**({"planVerification": completion} if completion else {}),
|
|
52
61
|
})
|
|
53
62
|
return code
|
|
54
63
|
except DispatchError as exc:
|
|
@@ -12,7 +12,11 @@ import os
|
|
|
12
12
|
import subprocess
|
|
13
13
|
from pathlib import Path
|
|
14
14
|
|
|
15
|
-
from .domain.worker_exec import
|
|
15
|
+
from .domain.worker_exec import (
|
|
16
|
+
REASONING_EFFORTS,
|
|
17
|
+
ExecutionPolicy,
|
|
18
|
+
WorkerExecRequest,
|
|
19
|
+
)
|
|
16
20
|
from .domain.worker_role import role_spec
|
|
17
21
|
from .write_policy import WriteEnforcement, WritePolicy
|
|
18
22
|
|
|
@@ -39,6 +43,7 @@ def build_request(
|
|
|
39
43
|
session_id: str = "",
|
|
40
44
|
write_policy: WritePolicy | None = None,
|
|
41
45
|
write_enforcement: WriteEnforcement | None = None,
|
|
46
|
+
reasoning_effort: str = "",
|
|
42
47
|
) -> WorkerExecRequest:
|
|
43
48
|
"""One dispatch, with every value the strategies will not re-derive.
|
|
44
49
|
|
|
@@ -46,6 +51,11 @@ def build_request(
|
|
|
46
51
|
strategies hand what they are given straight to a CLI, so an unresolved
|
|
47
52
|
`..` or symlink would survive into the sandbox boundary itself.
|
|
48
53
|
"""
|
|
54
|
+
if reasoning_effort and reasoning_effort not in REASONING_EFFORTS:
|
|
55
|
+
raise ValueError(
|
|
56
|
+
f"reasoning effort must be one of {', '.join(REASONING_EFFORTS)}: "
|
|
57
|
+
f"{reasoning_effort!r}"
|
|
58
|
+
)
|
|
49
59
|
root = project_root.resolve()
|
|
50
60
|
worktree = worktree_path.resolve() if worktree_path is not None else None
|
|
51
61
|
return WorkerExecRequest(
|
|
@@ -64,6 +74,7 @@ def build_request(
|
|
|
64
74
|
),
|
|
65
75
|
idle_timeout_seconds=idle_timeout_seconds,
|
|
66
76
|
session_id=session_id,
|
|
77
|
+
reasoning_effort=reasoning_effort,
|
|
67
78
|
)
|
|
68
79
|
|
|
69
80
|
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
"schemaVersion": {"const": "2.0"},
|
|
19
19
|
"executionIdentityVersion": {"const": 2},
|
|
20
20
|
"entryMode": {"enum": ["current-session", "new-session"]},
|
|
21
|
+
"reasoningEffort": {"enum": ["", "low", "medium", "high", "xhigh"]},
|
|
21
22
|
"participantAssignments": {
|
|
22
23
|
"type": "array",
|
|
23
24
|
"items": {"$ref": "#/$defs/ParticipantAssignment"}
|