okstra 0.214.1 → 0.216.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/README.md +2 -1
- package/dist/cli-registry.d.mts +14 -0
- package/dist/cli-registry.mjs +12 -0
- package/dist/cli-registry.mjs.map +1 -1
- package/dist/commands/chat/chat.mjs +2 -56
- package/dist/commands/chat/chat.mjs.map +1 -1
- package/dist/commands/execute/render-bundle.mjs +5 -2
- package/dist/commands/execute/render-bundle.mjs.map +1 -1
- package/dist/commands/execute/wizard.mjs +6 -0
- package/dist/commands/execute/wizard.mjs.map +1 -1
- package/dist/commands/memory/memory.mjs +57 -6
- package/dist/commands/memory/memory.mjs.map +1 -1
- package/dist/lib/file-lock.d.mts +1 -0
- package/dist/lib/file-lock.mjs +60 -0
- package/dist/lib/file-lock.mjs.map +1 -0
- package/dist/lib/python-command.mjs +11 -1
- package/dist/lib/python-command.mjs.map +1 -1
- package/dist/lib/skill-catalog.mjs +1 -0
- package/dist/lib/skill-catalog.mjs.map +1 -1
- package/dist/lib/types.d.mts +1 -1
- package/docs/architecture.md +2 -2
- package/docs/cli.md +36 -14
- package/docs/project-structure-overview.md +6 -3
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/bin/lib/okstra/cli.sh +9 -2
- package/runtime/bin/lib/okstra/globals.sh +2 -0
- package/runtime/bin/lib/okstra/interactive.sh +8 -2
- package/runtime/bin/lib/okstra/usage.sh +3 -1
- package/runtime/bin/okstra.sh +7 -1
- package/runtime/prompts/duties/discussion-worker.json +32 -0
- package/runtime/prompts/lead/convergence.md +58 -136
- package/runtime/prompts/lead/okstra-lead-contract.md +45 -12
- package/runtime/prompts/profiles/_common-contract.md +1 -1
- package/runtime/prompts/wizard/prompts.ko.json +14 -1
- package/runtime/python/okstra_ctl/adapters/providers/antigravity/adapter.py +4 -0
- package/runtime/python/okstra_ctl/adapters/providers/claude/adapter.py +4 -11
- package/runtime/python/okstra_ctl/agent/invocation.py +1 -0
- package/runtime/python/okstra_ctl/agent/prompt_cli/dynamic_verifier.py +2 -2
- package/runtime/python/okstra_ctl/agent/prompt_cli/materialize.py +4 -4
- package/runtime/python/okstra_ctl/agent/prompt_cli/run_identity.py +2 -2
- package/runtime/python/okstra_ctl/analysis_packet.py +3 -0
- package/runtime/python/okstra_ctl/consumers.py +8 -2
- package/runtime/python/okstra_ctl/convergence.py +143 -29
- package/runtime/python/okstra_ctl/convergence_critic_prompt.py +57 -0
- package/runtime/python/okstra_ctl/convergence_critic_verify_prompt.py +106 -0
- package/runtime/python/okstra_ctl/convergence_engine.py +140 -95
- package/runtime/python/okstra_ctl/convergence_reverify_prompt.py +3 -27
- package/runtime/python/okstra_ctl/convergence_store.py +11 -4
- package/runtime/python/okstra_ctl/dispatch_core.py +4 -1
- package/runtime/python/okstra_ctl/domain/role.py +1 -0
- package/runtime/python/okstra_ctl/manager_cli.py +27 -1
- package/runtime/python/okstra_ctl/manager_launch.py +44 -4
- package/runtime/python/okstra_ctl/manager_sync.py +14 -1
- package/runtime/python/okstra_ctl/manager_view.py +53 -1
- package/runtime/python/okstra_ctl/next_phase.py +1 -0
- package/runtime/python/okstra_ctl/phases/catalog.py +2 -1
- package/runtime/python/okstra_ctl/phases/discussion/__init__.py +1 -0
- package/runtime/python/okstra_ctl/phases/discussion/boundary.json +11 -0
- package/runtime/python/okstra_ctl/phases/discussion/cli.py +512 -0
- package/runtime/python/okstra_ctl/phases/discussion/entry.py +116 -0
- package/runtime/python/okstra_ctl/phases/discussion/followup.py +876 -0
- package/runtime/python/okstra_ctl/phases/discussion/memory.py +294 -0
- package/runtime/python/okstra_ctl/phases/discussion/overview.py +85 -0
- package/runtime/python/okstra_ctl/phases/discussion/profile.json +31 -0
- package/runtime/python/okstra_ctl/phases/discussion/profile.md +48 -0
- package/runtime/python/okstra_ctl/phases/discussion/report.py +73 -0
- package/runtime/python/okstra_ctl/phases/discussion/report_assets/discussion.template.html +15 -0
- package/runtime/python/okstra_ctl/phases/discussion/report_assets/discussion.template.md +6 -0
- package/runtime/python/okstra_ctl/phases/discussion/spec.md +197 -0
- package/runtime/python/okstra_ctl/phases/discussion/store.py +1143 -0
- package/runtime/python/okstra_ctl/phases/discussion/validation.py +111 -0
- package/runtime/python/okstra_ctl/phases/discussion/wizard.py +90 -0
- package/runtime/python/okstra_ctl/phases/discussion/workflow.py +667 -0
- package/runtime/python/okstra_ctl/phases/implementation/instructions/_implementation-deliverable.md +1 -1
- package/runtime/python/okstra_ctl/phases/implementation_planning/host-rules.md +4 -1
- package/runtime/python/okstra_ctl/phases/implementation_planning/instructions/plan-body-verification.md +1 -1
- package/runtime/python/okstra_ctl/phases/requirements_discovery/spec.md +1 -1
- package/runtime/python/okstra_ctl/plan_run_root.py +8 -0
- package/runtime/python/okstra_ctl/render.py +7 -20
- package/runtime/python/okstra_ctl/render_final_report.py +2 -1
- package/runtime/python/okstra_ctl/report_assembly.py +22 -0
- package/runtime/python/okstra_ctl/report_contract.py +2 -0
- package/runtime/python/okstra_ctl/report_narrative.py +13 -4
- package/runtime/python/okstra_ctl/report_synthesis_packet.py +10 -0
- package/runtime/python/okstra_ctl/run.py +40 -7
- package/runtime/python/okstra_ctl/stage_close.py +46 -11
- package/runtime/python/okstra_ctl/stage_reopen.py +151 -0
- package/runtime/python/okstra_ctl/verdict_blocks.py +11 -35
- package/runtime/python/okstra_ctl/wizard/cli.py +8 -0
- package/runtime/python/okstra_ctl/wizard/confirmation.py +4 -1
- package/runtime/python/okstra_ctl/wizard/engine.py +8 -0
- package/runtime/python/okstra_ctl/wizard/ids.py +12 -0
- package/runtime/python/okstra_ctl/wizard/registry.py +35 -3
- package/runtime/python/okstra_ctl/wizard/render.py +4 -1
- package/runtime/python/okstra_ctl/wizard/sources.py +2 -2
- package/runtime/python/okstra_ctl/wizard/state.py +11 -1
- package/runtime/python/okstra_ctl/wizard/steps_identity.py +4 -4
- package/runtime/python/okstra_ctl/worker_prompt_policy.py +24 -6
- package/runtime/python/okstra_token_usage/pricing.py +2 -0
- package/runtime/schemas/convergence-groups-v1.0.schema.json +1 -1
- package/runtime/schemas/convergence-groups-v2.0.schema.json +1 -1
- package/runtime/schemas/final-report-v3.0.schema.json +90 -1
- package/runtime/skills/_fragments/wizard-bash-invocation-rule.md +1 -0
- package/runtime/skills/_fragments/wizard-confirm-step.md +7 -0
- package/runtime/skills/_fragments/wizard-lead-takeover.md +8 -0
- package/runtime/skills/_fragments/wizard-preflight.md +75 -0
- package/runtime/skills/_fragments/wizard-prompt-loop.md +22 -0
- package/runtime/skills/_fragments/wizard-relay-protocol.md +40 -0
- package/runtime/skills/_fragments/wizard-render-bundle.md +74 -0
- package/runtime/skills/okstra-discussion/SKILL.md +347 -0
- package/runtime/skills/okstra-pr-gen/SKILL.md +7 -0
- package/runtime/skills/okstra-run/SKILL.md +22 -2
- package/runtime/templates/manager/view.template.html +3 -0
- package/runtime/templates/reports/html/i18n/en.json +5 -0
- package/runtime/templates/reports/html/i18n/ko.json +5 -0
- package/runtime/validators/validate-brief.py +3 -2
- package/runtime/validators/validate-run.py +4 -6
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""`okstra stage-reopen` — done 으로 닫힌 stage 를 사유와 함께 다시 연다.
|
|
2
|
+
|
|
3
|
+
stage 를 다시 여는 수단은 `done` 뒤에 `failed` 행을 붙이는 것이고
|
|
4
|
+
(`_implementation-deliverable.md` §"Reopening a settled stage"), 위저드는 마지막
|
|
5
|
+
행이 `failed` 인 stage 를 fix run 대상으로 받는다. 그 행을 쓰는 명령이 없어
|
|
6
|
+
리드가 `append_consumer` 를 직접 불러야 했다 — 실측(2026-10-07,
|
|
7
|
+
fontsninja-v3-site dev-11118): final-verification 이 `blocked` 로 끝나
|
|
8
|
+
`implementation` 을 가리켰는데 stage 1 이 `done` 이라 위저드가 거절했고,
|
|
9
|
+
consumers.jsonl 을 손으로 고치지 않는 한 fix run 을 시작할 길이 없었다.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import argparse
|
|
14
|
+
import json
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
from okstra_project import (
|
|
18
|
+
ResolverError,
|
|
19
|
+
StateError,
|
|
20
|
+
resolve_project_root,
|
|
21
|
+
resolve_task_identity,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
from .consumers import (
|
|
25
|
+
append_consumer,
|
|
26
|
+
last_lifecycle_status_by_stage,
|
|
27
|
+
latest_done_by_stage,
|
|
28
|
+
read_consumers,
|
|
29
|
+
)
|
|
30
|
+
from .paths import RunRef
|
|
31
|
+
from .stage_map import StageMapError, load_latest_plan_stage_map
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def reopen_stage(
|
|
35
|
+
project_root: Path, task_key: str, stage: int, reason: str, evidence: str = "",
|
|
36
|
+
) -> dict:
|
|
37
|
+
"""마지막 행이 `done` 인 stage 에 `failed` 행을 붙이고 그 근거를 돌려준다."""
|
|
38
|
+
if not reason.strip():
|
|
39
|
+
raise StateError("--reason is required: say why the stage is reopened",
|
|
40
|
+
stage="reason")
|
|
41
|
+
identity = resolve_task_identity(project_root, task_key)
|
|
42
|
+
task_root = Path(identity["taskRoot"])
|
|
43
|
+
plan_run_root = RunRef.from_task_root(task_root, "implementation-planning").run_dir
|
|
44
|
+
try:
|
|
45
|
+
stage_map = load_latest_plan_stage_map(task_root)
|
|
46
|
+
except StageMapError as exc:
|
|
47
|
+
raise StateError(str(exc), stage=exc.code) from exc
|
|
48
|
+
rows = [row for row in stage_map.stages if isinstance(row, dict)]
|
|
49
|
+
if not any(row.get("stage_number") == stage for row in rows):
|
|
50
|
+
raise StateError(f"stage {stage} is not in this task's Stage Map", stage="stage-map")
|
|
51
|
+
if any(row.get("stage_number") == stage and row.get("cancelled") for row in rows):
|
|
52
|
+
raise StateError(f"stage {stage} is cancelled in the latest plan", stage="stage-map")
|
|
53
|
+
|
|
54
|
+
consumers = read_consumers(plan_run_root)
|
|
55
|
+
recorded = last_lifecycle_status_by_stage(consumers).get(stage)
|
|
56
|
+
if recorded != "done":
|
|
57
|
+
raise StateError(
|
|
58
|
+
f"stage {stage} is recorded {recorded!r}, not 'done' — only a settled "
|
|
59
|
+
"stage is reopened; a 'failed' stage is already selectable for a fix run",
|
|
60
|
+
stage="consumers",
|
|
61
|
+
)
|
|
62
|
+
evidence_rel = ""
|
|
63
|
+
if evidence:
|
|
64
|
+
evidence_path = Path(evidence)
|
|
65
|
+
if not evidence_path.is_absolute():
|
|
66
|
+
evidence_path = project_root / evidence_path
|
|
67
|
+
if not evidence_path.is_file():
|
|
68
|
+
raise StateError(f"--evidence names no file: {evidence_path}", stage="evidence")
|
|
69
|
+
evidence_rel = evidence_path.resolve().relative_to(project_root.resolve()).as_posix()
|
|
70
|
+
done_row = latest_done_by_stage(
|
|
71
|
+
[row for row in consumers if row.get("status") == "done"]
|
|
72
|
+
)[stage]
|
|
73
|
+
head = str(done_row.get("head_commit") or "")
|
|
74
|
+
append_consumer(
|
|
75
|
+
plan_run_root,
|
|
76
|
+
impl_task_key=identity["taskKey"],
|
|
77
|
+
stage=stage,
|
|
78
|
+
status="failed",
|
|
79
|
+
head_commit=head,
|
|
80
|
+
reason=reason.strip(),
|
|
81
|
+
reopened_by="stage-reopen",
|
|
82
|
+
**({"evidence_path": evidence_rel} if evidence_rel else {}),
|
|
83
|
+
)
|
|
84
|
+
return {
|
|
85
|
+
"taskKey": identity["taskKey"],
|
|
86
|
+
"stage": stage,
|
|
87
|
+
"headCommit": head,
|
|
88
|
+
"reason": reason.strip(),
|
|
89
|
+
"evidencePath": evidence_rel,
|
|
90
|
+
"consumersPath": str(plan_run_root / "consumers.jsonl"),
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
_CLI_EPILOG = r"""Usage:
|
|
95
|
+
okstra stage-reopen <task-key> --stage <N> --reason "<why>" [--evidence <path>]
|
|
96
|
+
|
|
97
|
+
Appends a `failed` row after the stage's `done` row, so `/okstra-run` offers the
|
|
98
|
+
stage again for a fix run and its dependents stop branching from the withdrawn
|
|
99
|
+
head. Use it when a later verification found a defect in a done stage — for
|
|
100
|
+
example a final-verification that ended `blocked` and points at
|
|
101
|
+
`implementation`; pass that report as --evidence. Refuses a stage that is not in
|
|
102
|
+
the latest Stage Map, is cancelled, or whose last row is not `done`.
|
|
103
|
+
|
|
104
|
+
Output: JSON { ok, taskKey, stage, headCommit, reason, evidencePath,
|
|
105
|
+
consumersPath }. Exit 1 on a refusal, 2 when PROJECT_ROOT cannot be resolved.
|
|
106
|
+
"""
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def main(argv: list[str] | None = None) -> int:
|
|
110
|
+
parser = argparse.ArgumentParser(
|
|
111
|
+
description="Reopen a done implementation stage for a fix run.",
|
|
112
|
+
epilog=_CLI_EPILOG,
|
|
113
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
114
|
+
prog="okstra stage-reopen")
|
|
115
|
+
parser.add_argument("task_key", metavar="task-key",
|
|
116
|
+
help="project-id:task-group:task-id")
|
|
117
|
+
parser.add_argument("--stage", type=int, required=True,
|
|
118
|
+
help="the Stage Map number to reopen")
|
|
119
|
+
parser.add_argument("--reason", required=True,
|
|
120
|
+
help="why the stage is reopened; recorded on the row")
|
|
121
|
+
parser.add_argument("--evidence", default="",
|
|
122
|
+
help="the report that found the defect (recorded as a "
|
|
123
|
+
"project-relative path)")
|
|
124
|
+
parser.add_argument("--project-root", "--project", default="",
|
|
125
|
+
help="use this directory as PROJECT_ROOT")
|
|
126
|
+
parser.add_argument("--cwd", default=".",
|
|
127
|
+
help="resolve PROJECT_ROOT starting from here")
|
|
128
|
+
args = parser.parse_args(argv)
|
|
129
|
+
|
|
130
|
+
def emit(payload: dict) -> None:
|
|
131
|
+
print(json.dumps(payload, ensure_ascii=False, indent=2))
|
|
132
|
+
|
|
133
|
+
try:
|
|
134
|
+
resolved = resolve_project_root(explicit_root=args.project_root, cwd=args.cwd)
|
|
135
|
+
except ResolverError as exc:
|
|
136
|
+
emit({"ok": False, "stage": "resolve", "reason": str(exc)})
|
|
137
|
+
return 2
|
|
138
|
+
try:
|
|
139
|
+
reopened = reopen_stage(
|
|
140
|
+
Path(resolved), args.task_key, args.stage, args.reason, args.evidence,
|
|
141
|
+
)
|
|
142
|
+
except StateError as exc:
|
|
143
|
+
emit({"ok": False, "stage": getattr(exc, "stage", None) or "stage-reopen",
|
|
144
|
+
"reason": str(exc)})
|
|
145
|
+
return 1
|
|
146
|
+
emit({"ok": True, **reopened})
|
|
147
|
+
return 0
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
if __name__ == "__main__":
|
|
151
|
+
raise SystemExit(main())
|
|
@@ -24,18 +24,9 @@ VERDICT_TOKENS = frozenset({
|
|
|
24
24
|
})
|
|
25
25
|
FIXABILITY_VALUES = frozenset({"planner-fixable", "needs-user-input"})
|
|
26
26
|
|
|
27
|
-
# The convergence reverify
|
|
28
|
-
#
|
|
29
|
-
#
|
|
30
|
-
# have to be translated back (`prompts/lead/convergence.md`
|
|
31
|
-
# §"Adversarial Re-verification Prompt").
|
|
32
|
-
COLLABORATIVE_VERDICTS = {
|
|
33
|
-
"AGREE": "agree",
|
|
34
|
-
"DISAGREE": "disagree",
|
|
35
|
-
"SUPPLEMENT": "supplement",
|
|
36
|
-
"UNVERIFIABLE": "unverifiable",
|
|
37
|
-
"VERIFICATION-ERROR": "verification-error",
|
|
38
|
-
}
|
|
27
|
+
# The convergence reverify prompt asks the verifier to break the finding, so it
|
|
28
|
+
# answers in break/survive terms that have to be translated back to the schema's
|
|
29
|
+
# words (`prompts/lead/convergence.md` §"Adversarial Re-verification Prompt").
|
|
39
30
|
ADVERSARIAL_VERDICTS = {
|
|
40
31
|
"SURVIVES": "agree",
|
|
41
32
|
"SURVIVES-WITH-CAVEAT": "supplement",
|
|
@@ -156,16 +147,11 @@ def _scan_blocks(text: str) -> dict[str, dict[str, str]]:
|
|
|
156
147
|
}
|
|
157
148
|
|
|
158
149
|
|
|
159
|
-
def parse_finding_votes(text: str
|
|
160
|
-
"""Convergence reverify votes in *text*, keyed by finding id.
|
|
161
|
-
|
|
162
|
-
*adversarial* selects the vocabulary; guessing it from the content would make
|
|
163
|
-
an unfamiliar token silently read as a different verdict.
|
|
164
|
-
"""
|
|
165
|
-
vocabulary = ADVERSARIAL_VERDICTS if adversarial else COLLABORATIVE_VERDICTS
|
|
150
|
+
def parse_finding_votes(text: str) -> dict[str, FindingVote]:
|
|
151
|
+
"""Convergence reverify votes in *text*, keyed by finding id."""
|
|
166
152
|
return _collect_blocks(
|
|
167
153
|
_scan_blocks(text),
|
|
168
|
-
lambda item_id, fields: _finding_vote(item_id, fields,
|
|
154
|
+
lambda item_id, fields: _finding_vote(item_id, fields, ADVERSARIAL_VERDICTS),
|
|
169
155
|
)
|
|
170
156
|
|
|
171
157
|
|
|
@@ -178,22 +164,12 @@ def finding_vote_defect(text: str) -> str | None:
|
|
|
178
164
|
(`okstra_ctl.dispatch_state.unusable_result_defect`). 그러지 않으면 원장은
|
|
179
165
|
`ok`, 수집기는 거절이 되어 리드가 손으로 재띄우거나 run 을 닫는 수밖에 없다
|
|
180
166
|
(2026-09-10 실측, fontsninja-v3-site dev-10631 implementation-option-selection).
|
|
181
|
-
|
|
182
|
-
라운드가 adversarial 인지 collaborative 인지는 결과 파일에 적혀 있지 않다. 한쪽
|
|
183
|
-
어휘로만 읽으면 반대쪽 라운드의 정상 결과를 결함으로 신고하므로, 어느 한쪽으로
|
|
184
|
-
읽히면 결함이 아니다. 둘 다 실패했을 때만 adversarial 쪽 메시지를 낸다 — 실측된
|
|
185
|
-
결함(설명 누락)은 두 어휘에서 같은 문장을 낸다.
|
|
186
167
|
"""
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
if adversarial:
|
|
193
|
-
adversarial_error = str(exc)
|
|
194
|
-
continue
|
|
195
|
-
return None
|
|
196
|
-
return adversarial_error
|
|
168
|
+
try:
|
|
169
|
+
parse_finding_votes(text)
|
|
170
|
+
except VerdictBlockError as exc:
|
|
171
|
+
return str(exc)
|
|
172
|
+
return None
|
|
197
173
|
|
|
198
174
|
|
|
199
175
|
def _collect_blocks(blocks: dict[str, dict[str, str]], parse) -> dict:
|
|
@@ -7,6 +7,7 @@ from pathlib import Path
|
|
|
7
7
|
|
|
8
8
|
from okstra_ctl.registry.host_registry import default_host_registry
|
|
9
9
|
|
|
10
|
+
from .ids import REQUESTABLE_TASK_TYPES
|
|
10
11
|
from .state import WizardError
|
|
11
12
|
from .statefile import load_state_file, save_state_file
|
|
12
13
|
from .confirmation import confirmation_block
|
|
@@ -43,6 +44,12 @@ def main(argv: list[str]) -> int:
|
|
|
43
44
|
p_init.add_argument("--project-root", required=True)
|
|
44
45
|
p_init.add_argument("--project-id", required=True)
|
|
45
46
|
p_init.add_argument("--preferred-task-key", default="")
|
|
47
|
+
p_init.add_argument(
|
|
48
|
+
"--task-type",
|
|
49
|
+
default="",
|
|
50
|
+
choices=("", *REQUESTABLE_TASK_TYPES),
|
|
51
|
+
help="ask the task type before the brief and recommend this type",
|
|
52
|
+
)
|
|
46
53
|
p_init.add_argument(
|
|
47
54
|
"--host-runtime",
|
|
48
55
|
default="claude-code",
|
|
@@ -82,6 +89,7 @@ def main(argv: list[str]) -> int:
|
|
|
82
89
|
project_root=args.project_root,
|
|
83
90
|
project_id=args.project_id,
|
|
84
91
|
preferred_task_key=args.preferred_task_key,
|
|
92
|
+
requested_task_type=args.task_type,
|
|
85
93
|
host_runtime=args.host_runtime,
|
|
86
94
|
host_entry_mode=args.entry_mode,
|
|
87
95
|
available_functions=args.available_function,
|
|
@@ -221,7 +221,10 @@ def confirmation_block(state: WizardState) -> str:
|
|
|
221
221
|
lines: list[str] = [header]
|
|
222
222
|
lines.append(f" task-type : {state.task_type}")
|
|
223
223
|
lines.append(f" task-key : {state.task_group}/{state.task_id}")
|
|
224
|
-
|
|
224
|
+
if state.task_type == "discussion":
|
|
225
|
+
lines.append(f" discussion-start: {state.discussion_start}")
|
|
226
|
+
else:
|
|
227
|
+
lines.append(f" brief : {state.brief_path or '(none)'}")
|
|
225
228
|
if state.analysis_target:
|
|
226
229
|
lines.append(f" analysis-target: {state.analysis_target}")
|
|
227
230
|
if state.feature_evidence_path or state.project_evidence_path:
|
|
@@ -20,6 +20,7 @@ from .ids import (
|
|
|
20
20
|
GROUP_LABELS,
|
|
21
21
|
GROUP_MAX_TABS,
|
|
22
22
|
PROMPT_GROUPS,
|
|
23
|
+
REQUESTABLE_TASK_TYPES,
|
|
23
24
|
S_ABORTED,
|
|
24
25
|
S_CONFIRM,
|
|
25
26
|
S_DESIGN_PREP_CONFIRM,
|
|
@@ -50,13 +51,20 @@ def init_state(
|
|
|
50
51
|
host_entry_mode: str = "current-session",
|
|
51
52
|
available_functions: Optional[list[str]] = None,
|
|
52
53
|
preferred_task_key: str = "",
|
|
54
|
+
requested_task_type: str = "",
|
|
53
55
|
) -> WizardState:
|
|
54
56
|
"""Bootstrap a new wizard state."""
|
|
57
|
+
if requested_task_type and requested_task_type not in REQUESTABLE_TASK_TYPES:
|
|
58
|
+
raise WizardError(
|
|
59
|
+
f"--task-type accepts only {', '.join(REQUESTABLE_TASK_TYPES)}; "
|
|
60
|
+
f"got {requested_task_type!r}"
|
|
61
|
+
)
|
|
55
62
|
return WizardState(
|
|
56
63
|
workspace_root=workspace_root,
|
|
57
64
|
project_root=project_root,
|
|
58
65
|
project_id=project_id,
|
|
59
66
|
preferred_task_key=preferred_task_key,
|
|
67
|
+
requested_task_type=requested_task_type,
|
|
60
68
|
host_runtime=host_runtime,
|
|
61
69
|
host_entry_mode=host_entry_mode,
|
|
62
70
|
available_functions=list(available_functions or []),
|
|
@@ -26,6 +26,7 @@ TASK_TYPES: list[tuple[str, str]] = [
|
|
|
26
26
|
("implementation", "Execute approved plan (requires approved final-report)"),
|
|
27
27
|
("final-verification", "Acceptance + residual-risk review"),
|
|
28
28
|
("release-handoff", "Drive commit/push/PR — reuse the implementation task-key (new keys fail the empty-commits gate)"),
|
|
29
|
+
("discussion", "Settle requirements and direction with the user and several agents (no code changes)"),
|
|
29
30
|
]
|
|
30
31
|
|
|
31
32
|
|
|
@@ -50,6 +51,11 @@ _BRIEF_ENTRY_TASK_TYPES = (
|
|
|
50
51
|
)
|
|
51
52
|
|
|
52
53
|
|
|
54
|
+
# `okstra wizard init --task-type` 로 미리 고를 수 있는 유형. brief 없이 시작하는
|
|
55
|
+
# 유형만 둔다 — 새 task 에서 brief 질문보다 유형 질문이 먼저 와야 하기 때문이다.
|
|
56
|
+
REQUESTABLE_TASK_TYPES = ("discussion",)
|
|
57
|
+
|
|
58
|
+
|
|
53
59
|
CANONICAL_BASE_REFS = ["main", "dev", "staging", "preprod", "prod"]
|
|
54
60
|
|
|
55
61
|
|
|
@@ -136,6 +142,12 @@ S_BRIEF_PATH = "brief_path"
|
|
|
136
142
|
S_BRIEF_CARRY = "brief_carry"
|
|
137
143
|
|
|
138
144
|
|
|
145
|
+
S_DISCUSSION_START_PICK = "discussion_start_pick"
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
S_DISCUSSION_START = "discussion_start"
|
|
149
|
+
|
|
150
|
+
|
|
139
151
|
S_FEATURE_EVIDENCE_PICK = "feature_evidence_pick"
|
|
140
152
|
|
|
141
153
|
|
|
@@ -8,6 +8,13 @@ from okstra_ctl.phases.change_impact_analysis.entry import (
|
|
|
8
8
|
_submit_feature_evidence_pick,
|
|
9
9
|
)
|
|
10
10
|
|
|
11
|
+
from okstra_ctl.phases.discussion.wizard import (
|
|
12
|
+
build_discussion_start,
|
|
13
|
+
build_discussion_start_pick,
|
|
14
|
+
submit_discussion_start,
|
|
15
|
+
submit_discussion_start_pick,
|
|
16
|
+
)
|
|
17
|
+
|
|
11
18
|
from okstra_ctl.phases.implementation_planning.wizard import (
|
|
12
19
|
_selected_direction_options,
|
|
13
20
|
_planning_rerun_selected,
|
|
@@ -40,6 +47,8 @@ from .ids import (
|
|
|
40
47
|
S_DESIGN_PREP_OVERRIDES,
|
|
41
48
|
S_DIRECTIVE,
|
|
42
49
|
S_DIRECTIVE_PICK,
|
|
50
|
+
S_DISCUSSION_START,
|
|
51
|
+
S_DISCUSSION_START_PICK,
|
|
43
52
|
S_EDIT_TARGET,
|
|
44
53
|
S_FEATURE_EVIDENCE,
|
|
45
54
|
S_FEATURE_EVIDENCE_PICK,
|
|
@@ -286,7 +295,12 @@ STEPS: list[Step] = [
|
|
|
286
295
|
and not s.brief_path_pending_text
|
|
287
296
|
and S_BRIEF_PATH_PICK not in s.answered
|
|
288
297
|
and (
|
|
289
|
-
|
|
298
|
+
# `--task-type` 로 미리 고른 새 task 는 유형을 먼저 묻고, 그 유형이
|
|
299
|
+
# brief 를 받는 경우에만 brief 를 묻는다.
|
|
300
|
+
(s.is_new_task is True and bool(s.task_group)
|
|
301
|
+
and (not s.requested_task_type
|
|
302
|
+
or (S_TASK_TYPE in s.answered
|
|
303
|
+
and s.task_type not in ("", "discussion"))))
|
|
290
304
|
or (s.is_new_task is False
|
|
291
305
|
and S_TASK_TYPE in s.answered
|
|
292
306
|
and s.task_type in _BRIEF_ENTRY_TASK_TYPES
|
|
@@ -304,7 +318,9 @@ STEPS: list[Step] = [
|
|
|
304
318
|
Step(S_TASK_TYPE,
|
|
305
319
|
applies=lambda s: (s.is_new_task is not None
|
|
306
320
|
and (s.is_new_task is False
|
|
307
|
-
or (bool(s.task_group)
|
|
321
|
+
or (bool(s.task_group)
|
|
322
|
+
and (bool(s.brief_path)
|
|
323
|
+
or bool(s.requested_task_type))))
|
|
308
324
|
and S_TASK_TYPE not in s.answered),
|
|
309
325
|
build=_build_task_type, submit=_submit_task_type,
|
|
310
326
|
owns=("task_type", "reuse_worktree")),
|
|
@@ -320,7 +336,7 @@ STEPS: list[Step] = [
|
|
|
320
336
|
applies=lambda s: (S_TASK_TYPE in s.answered
|
|
321
337
|
and bool(s.task_type)
|
|
322
338
|
and s.task_type not in _BRIEF_ENTRY_TASK_TYPES
|
|
323
|
-
and s.task_type
|
|
339
|
+
and s.task_type not in ("release-handoff", "discussion")
|
|
324
340
|
and not s.brief_path
|
|
325
341
|
and not s.brief_path_pending_text),
|
|
326
342
|
build=_build_brief_carry, submit=_submit_brief_carry,
|
|
@@ -341,6 +357,22 @@ STEPS: list[Step] = [
|
|
|
341
357
|
and s.task_id_pending_text),
|
|
342
358
|
build=_build_task_id_text, submit=_submit_task_id_text,
|
|
343
359
|
owns=("task_id", "task_id_pending_text")),
|
|
360
|
+
# discussion 은 brief 대신 시작 입력을 받는다. task key 가 정해진 뒤에 물어야
|
|
361
|
+
# "이 task 의 토론 이어가기" 를 판정할 수 있어 task-id 다음에 선다.
|
|
362
|
+
Step(S_DISCUSSION_START_PICK,
|
|
363
|
+
applies=lambda s: (s.task_type == "discussion"
|
|
364
|
+
and bool(s.task_id)
|
|
365
|
+
and not s.discussion_start
|
|
366
|
+
and not s.discussion_start_pending_text
|
|
367
|
+
and S_DISCUSSION_START_PICK not in s.answered),
|
|
368
|
+
build=build_discussion_start_pick, submit=submit_discussion_start_pick,
|
|
369
|
+
owns=("discussion_start", "discussion_start_pending_text")),
|
|
370
|
+
Step(S_DISCUSSION_START,
|
|
371
|
+
applies=lambda s: (s.task_type == "discussion"
|
|
372
|
+
and s.discussion_start_pending_text
|
|
373
|
+
and S_DISCUSSION_START not in s.answered),
|
|
374
|
+
build=build_discussion_start, submit=submit_discussion_start,
|
|
375
|
+
owns=("discussion_start", "discussion_start_pending_text")),
|
|
344
376
|
Step(S_REUSE_PREVIOUS,
|
|
345
377
|
applies=lambda s: (_identity_ready(s)
|
|
346
378
|
and s.reuse_previous is None
|
|
@@ -99,7 +99,8 @@ def render_args(state: WizardState) -> dict[str, Any]:
|
|
|
99
99
|
"task-id": state.task_id,
|
|
100
100
|
"task-type": state.task_type,
|
|
101
101
|
"task-brief": (
|
|
102
|
-
"" if state.task_type
|
|
102
|
+
"" if state.task_type in ("release-handoff", "discussion")
|
|
103
|
+
else state.brief_path
|
|
103
104
|
),
|
|
104
105
|
"analysis-target": state.analysis_target,
|
|
105
106
|
"evidence-inputs": ",".join(evidence_paths),
|
|
@@ -121,6 +122,8 @@ def render_args(state: WizardState) -> dict[str, Any]:
|
|
|
121
122
|
_host_session_context(state)
|
|
122
123
|
),
|
|
123
124
|
}
|
|
125
|
+
if state.task_type == "discussion":
|
|
126
|
+
rendered["discussion-start"] = state.discussion_start
|
|
124
127
|
role_argv = render_role_args(state)
|
|
125
128
|
rendered["role-count"] = [
|
|
126
129
|
role_argv[index + 1]
|
|
@@ -198,8 +198,8 @@ def _resolve_reuse_worktree(state: WizardState) -> bool:
|
|
|
198
198
|
|
|
199
199
|
|
|
200
200
|
def _brief_resolved(state: WizardState) -> bool:
|
|
201
|
-
"""brief 입력이 끝났는가. release-handoff
|
|
202
|
-
return bool(state.brief_path) or state.task_type
|
|
201
|
+
"""brief 입력이 끝났는가. release-handoff·discussion 은 brief 를 받지 않아 항상 True."""
|
|
202
|
+
return bool(state.brief_path) or state.task_type in ("release-handoff", "discussion")
|
|
203
203
|
|
|
204
204
|
|
|
205
205
|
def _parse_stage_objects(state: WizardState) -> list:
|
|
@@ -40,6 +40,7 @@ class WizardState:
|
|
|
40
40
|
project_root: str = ""
|
|
41
41
|
project_id: str = ""
|
|
42
42
|
preferred_task_key: str = ""
|
|
43
|
+
requested_task_type: str = ""
|
|
43
44
|
host_runtime: str = "claude-code"
|
|
44
45
|
host_entry_mode: str = "current-session"
|
|
45
46
|
available_functions: list[str] = field(default_factory=list)
|
|
@@ -66,6 +67,10 @@ class WizardState:
|
|
|
66
67
|
brief_path: str = ""
|
|
67
68
|
brief_path_pending_text: bool = False
|
|
68
69
|
|
|
70
|
+
# discussion: brief 대신 받는 시작 입력(링크·짧은 설명·이 task 의 key)
|
|
71
|
+
discussion_start: str = ""
|
|
72
|
+
discussion_start_pending_text: bool = False
|
|
73
|
+
|
|
69
74
|
# analysis evidence and target
|
|
70
75
|
project_evidence_path: str = ""
|
|
71
76
|
project_evidence_pending_text: bool = False
|
|
@@ -566,7 +571,11 @@ def _identity_ready(s: WizardState) -> bool:
|
|
|
566
571
|
if not s.task_type:
|
|
567
572
|
return False
|
|
568
573
|
# release-handoff 는 brief 가 없다 — prepare 가 검증 보고서 인용 input 을 생성한다.
|
|
569
|
-
|
|
574
|
+
# discussion 은 brief 대신 시작 입력으로 prepare 가 input 문서를 만든다.
|
|
575
|
+
if s.task_type == "discussion":
|
|
576
|
+
if not s.discussion_start:
|
|
577
|
+
return False
|
|
578
|
+
elif not s.brief_path and s.task_type != "release-handoff":
|
|
570
579
|
return False
|
|
571
580
|
if _base_ref_required(s) and S_BASE_REF_PICK not in s.answered:
|
|
572
581
|
return False
|
|
@@ -588,6 +597,7 @@ _FIELD_DEFAULTS: dict[str, Any] = {
|
|
|
588
597
|
"task_group_pending_text": False, "task_id_pending_text": False,
|
|
589
598
|
"keep_existing_brief": None,
|
|
590
599
|
"brief_path": "", "brief_path_pending_text": False,
|
|
600
|
+
"discussion_start": "", "discussion_start_pending_text": False,
|
|
591
601
|
"project_evidence_path": "", "project_evidence_pending_text": False,
|
|
592
602
|
"feature_evidence_path": "", "feature_evidence_pending_text": False,
|
|
593
603
|
"analysis_target": "", "analysis_target_pending_text": False,
|
|
@@ -223,7 +223,7 @@ def _submit_task_pick(state: WizardState, value: str) -> Optional[str]:
|
|
|
223
223
|
# task_type seeded from manifest's nextRecommendedPhase as the recommended default
|
|
224
224
|
root = find_task_root(Path(state.project_root), value)
|
|
225
225
|
manifest = (read_task_manifest(root) or {}) if root else {}
|
|
226
|
-
state.task_type = _recommended_task_type(state, manifest)
|
|
226
|
+
state.task_type = state.requested_task_type or _recommended_task_type(state, manifest)
|
|
227
227
|
return f"task: {value}"
|
|
228
228
|
|
|
229
229
|
|
|
@@ -606,7 +606,7 @@ def _build_task_type(state: WizardState) -> Prompt:
|
|
|
606
606
|
recommended = (revision_requested or state.task_type
|
|
607
607
|
or _recommended_task_type(state, manifest))
|
|
608
608
|
if not recommended and not workflow:
|
|
609
|
-
recommended = brief_phase or TASK_TYPE_VALUES[0]
|
|
609
|
+
recommended = state.requested_task_type or brief_phase or TASK_TYPE_VALUES[0]
|
|
610
610
|
if brief_phase and recommended == brief_phase:
|
|
611
611
|
recommended_suffix = t["options"].get("_BRIEF_SUFFIX", recommended_suffix)
|
|
612
612
|
pointer = next_phase.promote(workflow.get("nextRecommendedPhase"))
|
|
@@ -645,8 +645,8 @@ def _carry_in_existing_brief(state: WizardState) -> str:
|
|
|
645
645
|
"""downstream task-type 이 등록된 brief 를 자동 carry-in 한다. 주입한 경로(또는 '')."""
|
|
646
646
|
if state.task_type in _BRIEF_ENTRY_TASK_TYPES:
|
|
647
647
|
return ""
|
|
648
|
-
if state.task_type
|
|
649
|
-
return "" # prepare 가
|
|
648
|
+
if state.task_type in ("release-handoff", "discussion"):
|
|
649
|
+
return "" # prepare 가 brief 자리의 input 문서를 직접 생성한다
|
|
650
650
|
if state.brief_path or not state.existing_brief_path:
|
|
651
651
|
return ""
|
|
652
652
|
p = Path(state.existing_brief_path)
|
|
@@ -54,6 +54,7 @@ FINAL_VERIFICATION_HEADERS = (
|
|
|
54
54
|
)
|
|
55
55
|
SUPPORTED_TASK_TYPES = frozenset({
|
|
56
56
|
"technical-verification",
|
|
57
|
+
"discussion",
|
|
57
58
|
"requirements-discovery",
|
|
58
59
|
"error-analysis",
|
|
59
60
|
"implementation-option-selection",
|
|
@@ -71,6 +72,7 @@ SUPPORTED_TASK_TYPES = frozenset({
|
|
|
71
72
|
# observational default below: describe the area, do not design for it.
|
|
72
73
|
ANALYSIS_DUTY_BY_TASK_TYPE: dict[str, AgentAudience] = {
|
|
73
74
|
"technical-verification": "technical-verification-worker",
|
|
75
|
+
"discussion": "discussion-worker",
|
|
74
76
|
"requirements-discovery": "discovery-worker",
|
|
75
77
|
"improvement-discovery": "discovery-worker",
|
|
76
78
|
"error-analysis": "diagnosis-worker",
|
|
@@ -103,6 +105,9 @@ PLAN_VERIFY_DISPATCH_KIND_PREFIX = "plan-verify-r"
|
|
|
103
105
|
# The one coverage-census retry: the same analyser, only the cells its first
|
|
104
106
|
# result left unjudged. Unnumbered because the retry count is fixed at one.
|
|
105
107
|
CENSUS_GAPFILL_DISPATCH_KIND = "census-gapfill"
|
|
108
|
+
# 토론에서 사용자가 한 참여자에게 직접 한 질문. 질문마다 그 참여자 한 명에게만 새로
|
|
109
|
+
# 디스패치하므로 본문이 최초 분석 프롬프트와 다르다.
|
|
110
|
+
DISCUSSION_QUESTION_DISPATCH_KIND = "discussion-question"
|
|
106
111
|
|
|
107
112
|
|
|
108
113
|
def _numbered_round(dispatch_kind: str, prefix: str) -> int | None:
|
|
@@ -125,19 +130,29 @@ def is_verification_dispatch_kind(dispatch_kind: str) -> bool:
|
|
|
125
130
|
)
|
|
126
131
|
|
|
127
132
|
|
|
133
|
+
def is_reverify_dispatch_kind(dispatch_kind: str) -> bool:
|
|
134
|
+
"""finding 수렴의 번호 reverify 라운드인가."""
|
|
135
|
+
return _numbered_round(dispatch_kind, REVERIFY_DISPATCH_KIND_PREFIX) is not None
|
|
136
|
+
|
|
137
|
+
|
|
128
138
|
def is_plan_verify_dispatch_kind(dispatch_kind: str) -> bool:
|
|
129
139
|
"""계획 본문 검증 라운드인가. 검증 계약이 요구하는 렌더러 서명이 번호
|
|
130
140
|
reverify 와 다르므로 계약 검사가 이 둘을 갈라야 한다."""
|
|
131
141
|
return _numbered_round(dispatch_kind, PLAN_VERIFY_DISPATCH_KIND_PREFIX) is not None
|
|
132
142
|
|
|
133
143
|
|
|
134
|
-
def
|
|
144
|
+
def is_critic_verification(
|
|
135
145
|
*, task_type: str, assignment_ref: str, dispatch_kind: str,
|
|
136
146
|
) -> bool:
|
|
147
|
+
"""크리틱이 검증자로 투표하는 디스패치인가 — 계획 본문 라운드의 1:1 판정과
|
|
148
|
+
finding 수렴 라운드의 동률 판정."""
|
|
149
|
+
if task_type == "implementation-planning" and assignment_ref == "critic/scope" and (
|
|
150
|
+
is_plan_verify_dispatch_kind(dispatch_kind)
|
|
151
|
+
):
|
|
152
|
+
return True
|
|
137
153
|
return (
|
|
138
|
-
|
|
139
|
-
and
|
|
140
|
-
and is_plan_verify_dispatch_kind(dispatch_kind)
|
|
154
|
+
assignment_ref == critic_assignment_ref(task_type)
|
|
155
|
+
and is_reverify_dispatch_kind(dispatch_kind)
|
|
141
156
|
)
|
|
142
157
|
|
|
143
158
|
|
|
@@ -209,9 +224,12 @@ def resolve_prompt_plan(
|
|
|
209
224
|
# and unrequested work, not findings), so grouping it with the initial
|
|
210
225
|
# analysis prompts makes `validate_analysis_prompt_set` compare two prompts
|
|
211
226
|
# that are *supposed* to differ and fail every critic-enabled run. A census
|
|
212
|
-
# gap-fill body lists one worker's missing cells,
|
|
227
|
+
# gap-fill body lists one worker's missing cells, and a discussion question
|
|
228
|
+
# carries one participant's question, so both differ the same way.
|
|
213
229
|
analysis_equality_group = (
|
|
214
|
-
None if dispatch_kind in (
|
|
230
|
+
None if dispatch_kind in (
|
|
231
|
+
"critic", CENSUS_GAPFILL_DISPATCH_KIND, DISCUSSION_QUESTION_DISPATCH_KIND,
|
|
232
|
+
)
|
|
215
233
|
else "analysis-core"
|
|
216
234
|
)
|
|
217
235
|
analysis_duty: AgentAudience = (
|
|
@@ -72,6 +72,8 @@ CLAUDE_PRICING = {
|
|
|
72
72
|
"opus-4-6": (5.0, 6.25, 0.50, 25.0), # Opus 4.6 (legacy; pricing matches 4.7 per Anthropic)
|
|
73
73
|
"sonnet-4-6": (3.0, 3.75, 0.30, 15.0), # Sonnet 4.6 (cache prices derived from ratios)
|
|
74
74
|
"haiku-4-5": (1.0, 1.25, 0.10, 5.0), # Haiku 4.5 (cache prices derived from ratios)
|
|
75
|
+
# Haiku 5.5 bills prompts over 100K tokens at 5x; this row is the <=100K rate.
|
|
76
|
+
"haiku-5-5": (0.10, 0.125, 0.01, 0.50), # Haiku 5.5 (platform.claude.com pricing, read 2026-10-15)
|
|
75
77
|
|
|
76
78
|
# Claude 4 family fallbacks (Opus 4 / Sonnet 4 / Haiku 4 base).
|
|
77
79
|
"opus-4": (15.0, 18.75, 1.50, 75.0),
|
|
@@ -38,7 +38,7 @@
|
|
|
38
38
|
"additionalProperties": false,
|
|
39
39
|
"properties": {
|
|
40
40
|
"workerId": { "type": "string", "pattern": "\\S" },
|
|
41
|
-
"audience": { "enum": ["analysis", "lead", "report-writer"] }
|
|
41
|
+
"audience": { "enum": ["analysis", "lead", "report-writer", "critic"] }
|
|
42
42
|
}
|
|
43
43
|
},
|
|
44
44
|
"Group": {
|
|
@@ -56,7 +56,7 @@
|
|
|
56
56
|
"additionalProperties": false,
|
|
57
57
|
"properties": {
|
|
58
58
|
"workerId": { "type": "string", "pattern": "\\S" },
|
|
59
|
-
"audience": { "enum": ["analysis", "lead", "report-writer"] },
|
|
59
|
+
"audience": { "enum": ["analysis", "lead", "report-writer", "critic"] },
|
|
60
60
|
"participantRef": { "type": "string", "pattern": "\\S" },
|
|
61
61
|
"sourceRoleExecutionRef": { "type": "string", "pattern": "\\S" }
|
|
62
62
|
}
|