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.
Files changed (118) hide show
  1. package/README.md +2 -1
  2. package/dist/cli-registry.d.mts +14 -0
  3. package/dist/cli-registry.mjs +12 -0
  4. package/dist/cli-registry.mjs.map +1 -1
  5. package/dist/commands/chat/chat.mjs +2 -56
  6. package/dist/commands/chat/chat.mjs.map +1 -1
  7. package/dist/commands/execute/render-bundle.mjs +5 -2
  8. package/dist/commands/execute/render-bundle.mjs.map +1 -1
  9. package/dist/commands/execute/wizard.mjs +6 -0
  10. package/dist/commands/execute/wizard.mjs.map +1 -1
  11. package/dist/commands/memory/memory.mjs +57 -6
  12. package/dist/commands/memory/memory.mjs.map +1 -1
  13. package/dist/lib/file-lock.d.mts +1 -0
  14. package/dist/lib/file-lock.mjs +60 -0
  15. package/dist/lib/file-lock.mjs.map +1 -0
  16. package/dist/lib/python-command.mjs +11 -1
  17. package/dist/lib/python-command.mjs.map +1 -1
  18. package/dist/lib/skill-catalog.mjs +1 -0
  19. package/dist/lib/skill-catalog.mjs.map +1 -1
  20. package/dist/lib/types.d.mts +1 -1
  21. package/docs/architecture.md +2 -2
  22. package/docs/cli.md +36 -14
  23. package/docs/project-structure-overview.md +6 -3
  24. package/package.json +1 -1
  25. package/runtime/BUILD.json +2 -2
  26. package/runtime/bin/lib/okstra/cli.sh +9 -2
  27. package/runtime/bin/lib/okstra/globals.sh +2 -0
  28. package/runtime/bin/lib/okstra/interactive.sh +8 -2
  29. package/runtime/bin/lib/okstra/usage.sh +3 -1
  30. package/runtime/bin/okstra.sh +7 -1
  31. package/runtime/prompts/duties/discussion-worker.json +32 -0
  32. package/runtime/prompts/lead/convergence.md +58 -136
  33. package/runtime/prompts/lead/okstra-lead-contract.md +45 -12
  34. package/runtime/prompts/profiles/_common-contract.md +1 -1
  35. package/runtime/prompts/wizard/prompts.ko.json +14 -1
  36. package/runtime/python/okstra_ctl/adapters/providers/antigravity/adapter.py +4 -0
  37. package/runtime/python/okstra_ctl/adapters/providers/claude/adapter.py +4 -11
  38. package/runtime/python/okstra_ctl/agent/invocation.py +1 -0
  39. package/runtime/python/okstra_ctl/agent/prompt_cli/dynamic_verifier.py +2 -2
  40. package/runtime/python/okstra_ctl/agent/prompt_cli/materialize.py +4 -4
  41. package/runtime/python/okstra_ctl/agent/prompt_cli/run_identity.py +2 -2
  42. package/runtime/python/okstra_ctl/analysis_packet.py +3 -0
  43. package/runtime/python/okstra_ctl/consumers.py +8 -2
  44. package/runtime/python/okstra_ctl/convergence.py +143 -29
  45. package/runtime/python/okstra_ctl/convergence_critic_prompt.py +57 -0
  46. package/runtime/python/okstra_ctl/convergence_critic_verify_prompt.py +106 -0
  47. package/runtime/python/okstra_ctl/convergence_engine.py +140 -95
  48. package/runtime/python/okstra_ctl/convergence_reverify_prompt.py +3 -27
  49. package/runtime/python/okstra_ctl/convergence_store.py +11 -4
  50. package/runtime/python/okstra_ctl/dispatch_core.py +4 -1
  51. package/runtime/python/okstra_ctl/domain/role.py +1 -0
  52. package/runtime/python/okstra_ctl/manager_cli.py +27 -1
  53. package/runtime/python/okstra_ctl/manager_launch.py +44 -4
  54. package/runtime/python/okstra_ctl/manager_sync.py +14 -1
  55. package/runtime/python/okstra_ctl/manager_view.py +53 -1
  56. package/runtime/python/okstra_ctl/next_phase.py +1 -0
  57. package/runtime/python/okstra_ctl/phases/catalog.py +2 -1
  58. package/runtime/python/okstra_ctl/phases/discussion/__init__.py +1 -0
  59. package/runtime/python/okstra_ctl/phases/discussion/boundary.json +11 -0
  60. package/runtime/python/okstra_ctl/phases/discussion/cli.py +512 -0
  61. package/runtime/python/okstra_ctl/phases/discussion/entry.py +116 -0
  62. package/runtime/python/okstra_ctl/phases/discussion/followup.py +876 -0
  63. package/runtime/python/okstra_ctl/phases/discussion/memory.py +294 -0
  64. package/runtime/python/okstra_ctl/phases/discussion/overview.py +85 -0
  65. package/runtime/python/okstra_ctl/phases/discussion/profile.json +31 -0
  66. package/runtime/python/okstra_ctl/phases/discussion/profile.md +48 -0
  67. package/runtime/python/okstra_ctl/phases/discussion/report.py +73 -0
  68. package/runtime/python/okstra_ctl/phases/discussion/report_assets/discussion.template.html +15 -0
  69. package/runtime/python/okstra_ctl/phases/discussion/report_assets/discussion.template.md +6 -0
  70. package/runtime/python/okstra_ctl/phases/discussion/spec.md +197 -0
  71. package/runtime/python/okstra_ctl/phases/discussion/store.py +1143 -0
  72. package/runtime/python/okstra_ctl/phases/discussion/validation.py +111 -0
  73. package/runtime/python/okstra_ctl/phases/discussion/wizard.py +90 -0
  74. package/runtime/python/okstra_ctl/phases/discussion/workflow.py +667 -0
  75. package/runtime/python/okstra_ctl/phases/implementation/instructions/_implementation-deliverable.md +1 -1
  76. package/runtime/python/okstra_ctl/phases/implementation_planning/host-rules.md +4 -1
  77. package/runtime/python/okstra_ctl/phases/implementation_planning/instructions/plan-body-verification.md +1 -1
  78. package/runtime/python/okstra_ctl/phases/requirements_discovery/spec.md +1 -1
  79. package/runtime/python/okstra_ctl/plan_run_root.py +8 -0
  80. package/runtime/python/okstra_ctl/render.py +7 -20
  81. package/runtime/python/okstra_ctl/render_final_report.py +2 -1
  82. package/runtime/python/okstra_ctl/report_assembly.py +22 -0
  83. package/runtime/python/okstra_ctl/report_contract.py +2 -0
  84. package/runtime/python/okstra_ctl/report_narrative.py +13 -4
  85. package/runtime/python/okstra_ctl/report_synthesis_packet.py +10 -0
  86. package/runtime/python/okstra_ctl/run.py +40 -7
  87. package/runtime/python/okstra_ctl/stage_close.py +46 -11
  88. package/runtime/python/okstra_ctl/stage_reopen.py +151 -0
  89. package/runtime/python/okstra_ctl/verdict_blocks.py +11 -35
  90. package/runtime/python/okstra_ctl/wizard/cli.py +8 -0
  91. package/runtime/python/okstra_ctl/wizard/confirmation.py +4 -1
  92. package/runtime/python/okstra_ctl/wizard/engine.py +8 -0
  93. package/runtime/python/okstra_ctl/wizard/ids.py +12 -0
  94. package/runtime/python/okstra_ctl/wizard/registry.py +35 -3
  95. package/runtime/python/okstra_ctl/wizard/render.py +4 -1
  96. package/runtime/python/okstra_ctl/wizard/sources.py +2 -2
  97. package/runtime/python/okstra_ctl/wizard/state.py +11 -1
  98. package/runtime/python/okstra_ctl/wizard/steps_identity.py +4 -4
  99. package/runtime/python/okstra_ctl/worker_prompt_policy.py +24 -6
  100. package/runtime/python/okstra_token_usage/pricing.py +2 -0
  101. package/runtime/schemas/convergence-groups-v1.0.schema.json +1 -1
  102. package/runtime/schemas/convergence-groups-v2.0.schema.json +1 -1
  103. package/runtime/schemas/final-report-v3.0.schema.json +90 -1
  104. package/runtime/skills/_fragments/wizard-bash-invocation-rule.md +1 -0
  105. package/runtime/skills/_fragments/wizard-confirm-step.md +7 -0
  106. package/runtime/skills/_fragments/wizard-lead-takeover.md +8 -0
  107. package/runtime/skills/_fragments/wizard-preflight.md +75 -0
  108. package/runtime/skills/_fragments/wizard-prompt-loop.md +22 -0
  109. package/runtime/skills/_fragments/wizard-relay-protocol.md +40 -0
  110. package/runtime/skills/_fragments/wizard-render-bundle.md +74 -0
  111. package/runtime/skills/okstra-discussion/SKILL.md +347 -0
  112. package/runtime/skills/okstra-pr-gen/SKILL.md +7 -0
  113. package/runtime/skills/okstra-run/SKILL.md +22 -2
  114. package/runtime/templates/manager/view.template.html +3 -0
  115. package/runtime/templates/reports/html/i18n/en.json +5 -0
  116. package/runtime/templates/reports/html/i18n/ko.json +5 -0
  117. package/runtime/validators/validate-brief.py +3 -2
  118. 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 prompts use their own vocabularies. Collaborative and
28
- # full-reanalysis rounds speak the schema's own words; the adversarial round asks
29
- # the verifier to break the finding, so it answers in break/survive terms that
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, *, adversarial: bool) -> dict[str, FindingVote]:
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, vocabulary),
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
- adversarial_error: str | None = None
188
- for adversarial in (True, False):
189
- try:
190
- parse_finding_votes(text, adversarial=adversarial)
191
- except VerdictBlockError as exc:
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
- lines.append(f" brief : {state.brief_path or '(none)'}")
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
- (s.is_new_task is True and bool(s.task_group))
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) and bool(s.brief_path)))
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 != "release-handoff"
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 == "release-handoff" else state.brief_path
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 는 brief 가 없는 phase 라 항상 True."""
202
- return bool(state.brief_path) or state.task_type == "release-handoff"
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
- if not s.brief_path and s.task_type != "release-handoff":
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 == "release-handoff":
649
- return "" # prepare 가 검증 보고서 인용 input 문서를 자동 생성한다
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 is_plan_critic_verification(
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
- task_type == "implementation-planning"
139
- and assignment_ref == "critic/scope"
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, so it differs the same way.
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 ("critic", CENSUS_GAPFILL_DISPATCH_KIND)
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
  }