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,294 @@
1
+ """확정본별 Memory Book 등록 — 발행과 분리된 동기화 상태.
2
+
3
+ `<task-root>/discussion/memory/` 아래:
4
+
5
+ - `revision-NNN.<digest12>.md` — 등록할 요약. 결정·이견·위험·미확인 사항과 확정본 원본 참조만
6
+ 담고 토론 전문은 담지 않는다. 사용자가 고친 요약은 새 digest 의 새 파일이다.
7
+ - `revision-NNN.json` — 그 확정본의 등록 상태(`pending` / `registered` / `refused`)와 시도 이력.
8
+
9
+ Memory Book 형식의 소유자는 Node 의 `okstra memory add` 다. 여기서는 `--source-key` 로 그 명령을
10
+ 부를 뿐 형식을 다시 구현하지 않는다. 출처 키 하나에 기록 하나를 보장하는 것은 Node 쪽 잠금이다.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import hashlib
15
+ import json
16
+ import os
17
+ import subprocess
18
+ import tempfile
19
+ from pathlib import Path
20
+ from typing import Any, Optional
21
+
22
+ from okstra_ctl.json_boundary import JsonBoundaryError, load_owned_object, write_owned_object_atomic
23
+ from okstra_ctl.run_context import dir_flock
24
+
25
+ from .store import (
26
+ _LOCK_FILENAME,
27
+ _REVISION_SEPARATOR,
28
+ DiscussionStore,
29
+ DiscussionStoreError,
30
+ _now_iso,
31
+ _of,
32
+ _read_records,
33
+ parse_revision_id,
34
+ read_revision,
35
+ )
36
+
37
+ SCHEMA_VERSION = "1.0"
38
+ MEMORY_DIRNAME = "memory"
39
+ SOURCE_KEY_PREFIX = "discussion:"
40
+ PENDING, REGISTERED, REFUSED = "pending", "registered", "refused"
41
+ _TIMEOUT_SECONDS = 60
42
+ _SYNC_HINT = "okstra discussion memory-sync <task-key>"
43
+
44
+
45
+ def source_key(revision_id: str) -> str:
46
+ """확정본 id 는 project-id:task-group:task-id 와 완료 순번이라 프로젝트·작업·확정본을 함께 가리킨다."""
47
+ return SOURCE_KEY_PREFIX + revision_id
48
+
49
+
50
+ def _memory_dir(store: DiscussionStore) -> Path:
51
+ return store.dir / MEMORY_DIRNAME
52
+
53
+
54
+ def _state_path(store: DiscussionStore, sequence: int) -> Path:
55
+ return _memory_dir(store) / f"revision-{sequence:03d}.json"
56
+
57
+
58
+ def _relative(store: DiscussionStore, path: Path) -> str:
59
+ return path.resolve().relative_to(store.project_root.resolve()).as_posix()
60
+
61
+
62
+ def _digest(data: bytes) -> str:
63
+ return "sha256:" + hashlib.sha256(data).hexdigest()
64
+
65
+
66
+ def _bullets(rows: list[str]) -> list[str]:
67
+ return [f"- {row}" for row in rows] or ["- none"]
68
+
69
+
70
+ def render_summary(revision: dict[str, Any], publication: dict[str, Any]) -> str:
71
+ task_key = revision["taskKey"]
72
+ project_id = task_key.split(":", 1)[0]
73
+ lines = [
74
+ f"# Discussion outcome {revision['revisionId']}",
75
+ "",
76
+ "## Source",
77
+ "",
78
+ f"- Revision: `{revision['revisionId']}`",
79
+ f"- Content digest: `{publication['contentDigest']}`",
80
+ f"- Revision file: `{publication['revisionPath']}`",
81
+ f"- Project: `{project_id}`",
82
+ f"- Task: `{task_key}`",
83
+ f"- Read it with: `okstra discussion revision-show {task_key} --revision {revision['revisionId']}`",
84
+ "",
85
+ "## Subject",
86
+ "",
87
+ f"{revision['subject']['subject']} — {revision['subject']['question']}",
88
+ "",
89
+ "## Direction",
90
+ "",
91
+ revision["direction"],
92
+ "",
93
+ "## Decisions",
94
+ "",
95
+ *_bullets([f"{row['recordId']}: {row['text']}" for row in revision["decisions"]]),
96
+ "",
97
+ "## Disagreements",
98
+ "",
99
+ *_bullets([f"{row['recordId']} ({row['standing']}): {row['summary']}" for row in revision["disagreements"]]),
100
+ "",
101
+ "## Risks",
102
+ "",
103
+ revision["confirmation"]["riskAcknowledgement"],
104
+ "",
105
+ "## Unverified",
106
+ "",
107
+ *_bullets([f"{row['recordId']} ({row['kind']}): {row['detail']}" for row in revision["unverified"]]),
108
+ "",
109
+ ]
110
+ return "\n".join(lines)
111
+
112
+
113
+ def _write_text_atomic(path: Path, text: str) -> None:
114
+ path.parent.mkdir(parents=True, exist_ok=True)
115
+ descriptor, raw = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent)
116
+ temporary = Path(raw)
117
+ try:
118
+ with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
119
+ handle.write(text)
120
+ handle.flush()
121
+ os.fsync(handle.fileno())
122
+ os.replace(temporary, path)
123
+ finally:
124
+ temporary.unlink(missing_ok=True)
125
+
126
+
127
+ def _summary_path(store: DiscussionStore, sequence: int, digest: str) -> Path:
128
+ return _memory_dir(store) / f"revision-{sequence:03d}.{digest.split(':', 1)[1][:12]}.md"
129
+
130
+
131
+ def _load_state(path: Path) -> Optional[dict[str, Any]]:
132
+ if not path.is_file():
133
+ return None
134
+ try:
135
+ return load_owned_object(path, artifact="discussion memory state")
136
+ except JsonBoundaryError as exc:
137
+ raise DiscussionStoreError(str(exc)) from exc
138
+
139
+
140
+ def _save_state(path: Path, state: dict[str, Any]) -> None:
141
+ try:
142
+ write_owned_object_atomic(path, state, artifact="discussion memory state")
143
+ except JsonBoundaryError as exc:
144
+ raise DiscussionStoreError(str(exc)) from exc
145
+
146
+
147
+ def _set_summary(store: DiscussionStore, state: dict[str, Any], sequence: int, text: str, origin: str) -> None:
148
+ digest = _digest(text.encode("utf-8"))
149
+ path = _summary_path(store, sequence, digest)
150
+ _write_text_atomic(path, text)
151
+ state.update({"summaryPath": _relative(store, path), "summaryDigest": digest, "summaryOrigin": origin})
152
+
153
+
154
+ def _prepare_locked(store: DiscussionStore, revision_id: str, summary_text: Optional[str]) -> dict[str, Any]:
155
+ """요약과 출처를 먼저 기록한다 — 등록이 어디서 끊겨도 같은 키로 다시 부를 근거가 남는다."""
156
+ _, sequence = parse_revision_id(revision_id)
157
+ publication, data = read_revision(store, revision_id)
158
+ path = _state_path(store, sequence)
159
+ state = _load_state(path)
160
+ if state is None:
161
+ state = {
162
+ "schemaVersion": SCHEMA_VERSION, "revisionId": revision_id,
163
+ "contentDigest": publication["contentDigest"], "revisionPath": publication["revisionPath"],
164
+ "sourceKey": source_key(revision_id), "status": PENDING, "entryId": None, "entryPath": None,
165
+ "refusal": None, "reason": "not registered yet", "attempts": [],
166
+ }
167
+ _set_summary(store, state, sequence, render_summary(json.loads(data), publication), "generated")
168
+ if summary_text is not None:
169
+ if state["status"] == REGISTERED:
170
+ raise DiscussionStoreError(
171
+ f"{revision_id} is already registered as {state['entryId']}; a registered entry is not replaced"
172
+ )
173
+ if _digest(summary_text.encode("utf-8")) == state["summaryDigest"]:
174
+ raise DiscussionStoreError(
175
+ f"the summary of {revision_id} is unchanged; edit it before registering again"
176
+ )
177
+ _set_summary(store, state, sequence, summary_text, "user")
178
+ if state["status"] == REFUSED:
179
+ state.update({"status": PENDING, "reason": "the summary changed after the refusal"})
180
+ _save_state(path, state)
181
+ return state
182
+
183
+
184
+ def _run_memory_add(okstra_cli: str, store: DiscussionStore, state: dict[str, Any]) -> dict[str, Any]:
185
+ """결과를 등록·거부·일시 실패 셋으로 나눈다. 거부는 같은 입력에 같은 답이라 다시 부르지 않는다."""
186
+ if not okstra_cli:
187
+ return {"outcome": PENDING, "reason": "no okstra CLI path was given (--okstra-cli)"}
188
+ project_id = state["revisionId"].split(":", 1)[0]
189
+ command = [
190
+ okstra_cli, "memory", "add", "--file", str(store.project_root / state["summaryPath"]),
191
+ "--title", f"Discussion outcome {state['revisionId']}", "--type", "decision",
192
+ "--source", "discussion", "--source-key", state["sourceKey"], "--project", project_id,
193
+ "--tag", "discussion", "--yes", "--json",
194
+ ]
195
+ try:
196
+ completed = subprocess.run(command, capture_output=True, text=True, timeout=_TIMEOUT_SECONDS, check=False)
197
+ except (OSError, subprocess.TimeoutExpired) as exc:
198
+ return {"outcome": PENDING, "reason": f"okstra memory add did not run: {exc}"}
199
+ try:
200
+ payload = json.loads(completed.stdout)
201
+ except json.JSONDecodeError:
202
+ payload = None
203
+ if isinstance(payload, dict) and payload.get("refused") == "secret":
204
+ return {"outcome": REFUSED, "secrets": payload.get("secrets") or [], "reason": payload.get("message", "")}
205
+ entry = payload.get("entry") if isinstance(payload, dict) and payload.get("ok") else None
206
+ if completed.returncode == 0 and isinstance(entry, dict) and isinstance(entry.get("id"), str):
207
+ return {"outcome": REGISTERED, "entryId": entry["id"], "entryPath": payload.get("path"),
208
+ "created": bool(payload.get("created"))}
209
+ detail = (completed.stderr or completed.stdout).strip().splitlines()
210
+ return {"outcome": PENDING,
211
+ "reason": f"okstra memory add exited {completed.returncode}: {detail[-1] if detail else 'no output'}"}
212
+
213
+
214
+ def _record_locked(store: DiscussionStore, revision_id: str, summary_digest: str, result: dict[str, Any]) -> dict[str, Any]:
215
+ _, sequence = parse_revision_id(revision_id)
216
+ path = _state_path(store, sequence)
217
+ state = _load_state(path)
218
+ if state is None:
219
+ raise DiscussionStoreError(f"the memory state of {revision_id} disappeared during registration")
220
+ state["attempts"].append({
221
+ "at": _now_iso(), "summaryDigest": summary_digest, "outcome": result["outcome"],
222
+ "reason": result.get("reason", ""), "entryId": result.get("entryId"),
223
+ })
224
+ # 같은 확정본을 동시에 등록한 다른 호출이 먼저 끝냈으면 그 결과를 지킨다.
225
+ if state["status"] != REGISTERED and summary_digest == state["summaryDigest"]:
226
+ if result["outcome"] == REGISTERED:
227
+ state.update({"status": REGISTERED, "entryId": result["entryId"], "entryPath": result["entryPath"],
228
+ "reason": "", "refusal": None})
229
+ elif result["outcome"] == REFUSED:
230
+ state.update({"status": REFUSED, "reason": result["reason"], "refusal": {
231
+ "summaryDigest": summary_digest, "secrets": result["secrets"], "message": result["reason"]}})
232
+ else:
233
+ state.update({"status": PENDING, "reason": result["reason"]})
234
+ _save_state(path, state)
235
+ return state
236
+
237
+
238
+ def register_revision(
239
+ store: DiscussionStore, revision_id: str, *, okstra_cli: str, summary_text: Optional[str] = None,
240
+ ) -> dict[str, Any]:
241
+ with dir_flock(store.dir, _LOCK_FILENAME):
242
+ state = _prepare_locked(store, revision_id, summary_text)
243
+ if state["status"] in (REGISTERED, REFUSED):
244
+ return view(state)
245
+ result = _run_memory_add(okstra_cli, store, state)
246
+ with dir_flock(store.dir, _LOCK_FILENAME):
247
+ return view(_record_locked(store, revision_id, state["summaryDigest"], result))
248
+
249
+
250
+ def _publications(store: DiscussionStore) -> list[dict[str, Any]]:
251
+ rows, _ = _read_records(store)
252
+ return _of(rows, "publication")
253
+
254
+
255
+ def sync(
256
+ store: DiscussionStore, *, okstra_cli: str, revision_id: str = "", summary_text: Optional[str] = None,
257
+ ) -> list[dict[str, Any]]:
258
+ """발행된 확정본 가운데 등록되지 않은 것을 같은 출처 키로 다시 등록한다. 거부된 요약은 건너뛴다."""
259
+ if summary_text is not None and not revision_id:
260
+ raise DiscussionStoreError("--summary-file needs --revision: a summary belongs to one revision")
261
+ targets = [row["revisionId"] for row in _publications(store)]
262
+ if revision_id:
263
+ if revision_id not in targets:
264
+ raise DiscussionStoreError(f"no revision {revision_id} is published in this discussion")
265
+ targets = [revision_id]
266
+ return [register_revision(store, target, okstra_cli=okstra_cli,
267
+ summary_text=summary_text if target == revision_id else None)
268
+ for target in targets]
269
+
270
+
271
+ def view(state: dict[str, Any]) -> dict[str, Any]:
272
+ out = {key: state[key] for key in ("revisionId", "sourceKey", "status", "entryId", "summaryPath", "reason")}
273
+ out["attempts"] = len(state["attempts"])
274
+ if state["status"] == REFUSED:
275
+ out["secrets"] = state["refusal"]["secrets"]
276
+ task_key = state["revisionId"].rpartition(_REVISION_SEPARATOR)[0]
277
+ out["next"] = (f"edit a copy of {state['summaryPath']} without the secret and run `okstra discussion "
278
+ f"memory-sync {task_key} --revision {state['revisionId']} --summary-file <edited copy>`")
279
+ elif state["status"] == PENDING:
280
+ out["next"] = _SYNC_HINT
281
+ return out
282
+
283
+
284
+ def views(store: DiscussionStore) -> list[dict[str, Any]]:
285
+ """읽기만 한다. 상태 파일이 없는 발행은 아직 등록을 시도하지 않은 것이다."""
286
+ rows = []
287
+ for publication in _publications(store):
288
+ state = _load_state(_state_path(store, publication["sequence"]))
289
+ rows.append(view(state) if state is not None else {
290
+ "revisionId": publication["revisionId"], "sourceKey": source_key(publication["revisionId"]),
291
+ "status": PENDING, "entryId": None, "summaryPath": None, "reason": "not registered yet",
292
+ "attempts": 0, "next": _SYNC_HINT,
293
+ })
294
+ return rows
@@ -0,0 +1,85 @@
1
+ """관리자 조회용 토론 요약 — 원본을 복제하지 않고 식별자·상태·경로만 읽는다.
2
+
3
+ 토론 원본은 토론 task 아래에 있고, 후속 작업이 쓰는 확정본의 정본은 그 작업의 브리프다.
4
+ 여기서는 둘 다 다시 읽기만 하며 어느 프로젝트에도 쓰지 않는다. 읽을 수 없는 것은 `unknown`
5
+ 으로 돌려 오래된 값을 현재 상태로 보이지 않게 한다.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ from typing import Any
11
+
12
+ from okstra_ctl.json_boundary import JsonBoundaryError
13
+
14
+ from . import followup
15
+ from .store import DiscussionStore, DiscussionStoreError, has_records, project_draft, read_revision
16
+
17
+ UNKNOWN = "unknown"
18
+ _FOLLOW_UP_FIELDS = (
19
+ "followUp", "targetTaskKey", "targetProjectRoot", "briefPath", "state", "reason",
20
+ "linkedRevision", "pinnedRevision", "latestRevision", "applyRecord", "hasPlan", "planBasis",
21
+ "amendedPlan", "amendmentRecord",
22
+ )
23
+
24
+
25
+ def _questions(draft: dict[str, Any]) -> dict[str, list[dict[str, Any]]]:
26
+ by_id = {row["recordId"]: row for row in draft["questions"]}
27
+ return {
28
+ "forUser": [{key: by_id[qid][key] for key in ("recordId", "author", "text")}
29
+ for qid in draft["pendingForUser"]],
30
+ "forParticipants": [{key: by_id[qid][key] for key in ("recordId", "to", "text")}
31
+ for qid in draft["pendingForParticipants"]],
32
+ }
33
+
34
+
35
+ def _revisions(store: DiscussionStore, draft: dict[str, Any]) -> list[dict[str, Any]]:
36
+ rows = []
37
+ for publication in draft["revisions"]:
38
+ row = {key: publication[key] for key in ("revisionId", "sequence", "contentDigest", "revisionPath")}
39
+ try:
40
+ _, data = read_revision(store, publication["revisionId"])
41
+ previous = json.loads(data).get("previousRevision")
42
+ except (DiscussionStoreError, OSError, ValueError) as exc:
43
+ rows.append({**row, "state": UNKNOWN, "reason": str(exc)})
44
+ continue
45
+ rows.append({
46
+ **row, "state": "published",
47
+ "previousRevision": previous["revisionId"] if previous else None,
48
+ "changeReason": previous["changeReason"] if previous else None,
49
+ })
50
+ return rows
51
+
52
+
53
+ def _follow_ups(store: DiscussionStore) -> dict[str, Any]:
54
+ try:
55
+ view = followup.links(store)
56
+ except (DiscussionStoreError, OSError, JsonBoundaryError) as exc:
57
+ return {"state": UNKNOWN, "reason": f"the follow-up links cannot be read: {exc}", "items": [], "unlinked": []}
58
+ return {
59
+ "state": "read",
60
+ "items": [{key: row[key] for key in _FOLLOW_UP_FIELDS if key in row} for row in view["followUps"]],
61
+ "unlinked": view["unlinkedFollowUps"],
62
+ }
63
+
64
+
65
+ def discussion_overview(store: DiscussionStore) -> dict[str, Any]:
66
+ source = store.dir.relative_to(store.project_root).as_posix()
67
+ try:
68
+ if not has_records(store):
69
+ return {"state": UNKNOWN, "source": source, "reason": f"{source} holds no discussion record"}
70
+ draft = project_draft(store)
71
+ except (DiscussionStoreError, OSError) as exc:
72
+ return {"state": UNKNOWN, "source": source, "reason": f"the discussion record cannot be read: {exc}"}
73
+ based_on = draft["basedOn"]
74
+ return {
75
+ "state": "completed" if draft["completedRevision"] else "draft",
76
+ "source": source,
77
+ "phase": draft["phase"],
78
+ "draftVersion": draft["recordsDigest"],
79
+ "completedRevision": draft["completedRevision"],
80
+ "reopenedFrom": {"revisionId": based_on["revisionId"], "reason": based_on["reason"]} if based_on else None,
81
+ "pendingQuestions": _questions(draft),
82
+ "revisions": _revisions(store, draft),
83
+ "followUps": _follow_ups(store),
84
+ }
85
+
@@ -0,0 +1,31 @@
1
+ {
2
+ "schemaVersion": "1.0",
3
+ "id": "discussion",
4
+ "roles": [
5
+ {
6
+ "mode": "static",
7
+ "roleId": "analyser",
8
+ "dutyId": "discussion-worker",
9
+ "min": 1,
10
+ "recommended": 3,
11
+ "max": 5
12
+ },
13
+ {
14
+ "mode": "static",
15
+ "roleId": "report-writer",
16
+ "dutyId": "report-writer",
17
+ "min": 1,
18
+ "recommended": 1,
19
+ "max": 1
20
+ },
21
+ {
22
+ "mode": "dynamic",
23
+ "roleId": "verifier",
24
+ "dutyId": "reverification-worker",
25
+ "sourceRoleIds": [
26
+ "analyser"
27
+ ],
28
+ "activation": "per-selected-source"
29
+ }
30
+ ]
31
+ }
@@ -0,0 +1,48 @@
1
+ # Discussion Profile
2
+
3
+ - Purpose: settle the requirements and implementation direction of a ticket or project together with the user and several agents, preserving disagreements, before any follow-up task plans or implements the work
4
+ {{INCLUDE:_common-contract.md}}
5
+ - Facilitator (the `leader` role):
6
+ - confirm the discussion subject and the question to settle with the user before dispatching research
7
+ - keep the discussion record only through `okstra discussion` with this run's manifest as `--run-manifest`: `confirm-subject` for the confirmed subject and question, `ask` for a question to the user, `address` for the user's question to one participant, `answer` for every answer, `decide` for a user decision, and `draft` to read the record every participant gets as its next input. Never write or edit the files under `<task-root>/discussion/` yourself; a resend of the same record is harmless
8
+ - run `okstra discussion status` on entry and on resume; it names the phase and the one question to put to the user next
9
+ - relay the questions, contested points and refutation results to the invoking conversation, and put questions to the user one at a time, in the order `status` gives
10
+ - share every user answer, and every answer to a question the user addressed to one participant, with all participants as input to the next cycle; the draft every instruction carries already holds them
11
+ - record the user's intent and priorities as user decisions; record claims about the current code or external facts as claims that need evidence. A fact check never settles the user's intent
12
+ - record new evidence or a new alternative with `add-material`; only a start input, the subject, an answer or a material makes the next research input differ from the last one
13
+ - the discussion completes only when the user has seen the remaining disagreements and risks, chosen a direction and confirmed completion. Agent consensus, a convergence stop or the end of a worker batch is not completion
14
+ - Research cycle (one per run):
15
+ - run `okstra discussion cycle-open`. On `dispatch`, materialize one `initial` invocation per analyser with the returned `instructionPath` as `--instruction` and dispatch them as one batch. `await-user` / `await-participant` / `await-recovery` mean nothing is dispatched until that wait ends. `present-options` means the input references equal the last closed cycle's: present the remaining disagreements and options to the user instead of researching again. `prepare-run` means the next cycle needs a continuation run prepared with `--discussion-start <task-key>`
16
+ - after the batch settles, run `okstra discussion settle`, then group the factual claims for Phase 5.5 convergence; register each question only the user can answer with `ask --author <worker-id>` instead of grouping it
17
+ - after `okstra convergence finalize`, run `okstra discussion cycle-close`. It records the claims with their original authors and the verifiers' verdicts and ends this verification cycle only; the discussion stays a draft
18
+ - Direct questions:
19
+ - when the user asks one participant directly, record it with `address`, run `okstra discussion question-prompt`, materialize the batch file it names and dispatch it as a new invocation (no provider session is reused), then run `okstra discussion collect-answer`, which records the participant's whole result as the answer
20
+ - Failures:
21
+ - when `settle` or `collect-answer` reports a failure, show the user which participant failed and which scope was not verified, then record the user's choice with `okstra discussion recover`: `retry` (a new invocation in this run), `replace` (a continuation run with the new roster; completed results stay recorded with their authors) or `reduce` (continue with the remaining participants). Never replace a participant automatically
22
+ - Completion:
23
+ - record every settled requirement with `okstra discussion add-requirement` (`--applies-to <follow-up label>` when only one follow-up needs it) and every in-scope and excluded item with `add-scope`; record an explicit `none` when nothing is excluded. These belong to the current draft only. After `reopen`, the draft already holds the reopened revision's requirements and scope, each marked with the revision it was carried from: do not record them again. When the user drops one, record `okstra discussion withdraw --item <N-NNNN|B-NNNN> --reason <the user's reason>`; when the user changes one, withdraw it and add the new one
24
+ - show the user the draft (`okstra discussion draft`) with the remaining disagreements, the unverified scope and the reduced-roster history; ask the user to choose a direction and to confirm the risks they accept. Remaining disagreements do not block completion; the user's confirmation does
25
+ - run `okstra discussion confirm-completion` with the `draftVersion` that `status` printed for the draft the user saw, the user's direction and risk confirmation in their words. A refusal names what is missing (subject, requirement, in-scope or excluded entry, a closed research cycle, an unanswered question, a failure without the user's choice) or says the draft changed since that version: show the current draft and ask again. Never confirm on the user's behalf; a resend of the same confirmation returns the published revision
26
+ - `confirm-completion` also registers the revision in the Memory Book by itself; its `memory` field says `registered`, `pending` or `refused`. A `pending` registration (also listed by `status` under `memoryRegistration` on entry and on resume) is retried with `okstra discussion memory-sync`; the revision stays published either way. A `refused` one names the likely secrets: show them to the user and register again only with the summary the user edited, via `memory-sync --revision <revision-id> --summary-file <path>`. Never edit the files under `<task-root>/discussion/memory/` yourself
27
+ - the published revision under `<task-root>/discussion/revisions/` is the canonical outcome. Report assembly puts only its id and content digest into the final report's `discussion` block, or the draft version when the user has not confirmed; finalize the run either way. `okstra report-finalize` never confirms completion
28
+ - to change a completed discussion, prepare a continuation run with `--discussion-start <task-key>` and record `okstra discussion reopen --revision <latest revision id> --reason <why>` before any other record; the next revision carries that reference and reason, and its `itemChanges` lists what it carried, added and withdrawn. Earlier revisions stay readable byte-for-byte with `revision-show`; `record` prints the whole discussion record with every research result in full
29
+ - Follow-ups (only when the user asks, after a revision is published):
30
+ - write one follow-up task's brief with `okstra discussion follow-up-brief`: it carries the common requirements and those of that follow-up label, recommends `requirements-discovery` and pins the revision. Map a requirement to an end state with `--end-state <N-NNNN>=<EB|PB|EO>` only as the user states it; an observation method the revision does not carry leaves the brief `pending` for the user's confirmation (okstra-brief-gen Step 6.5). Never mark it confirmed for the user
31
+ - after a re-discussion publishes a newer revision, show `okstra discussion links` and `okstra discussion impact` to the user and record only the user's choice per follow-up with `okstra discussion apply --choice adopt|keep`. A follow-up that already has an implementation-planning plan is routed to the plan amendment path; nothing here edits a plan or source, and no follow-up run is started from this run
32
+ - a routed follow-up stays `amendment-pending` until the user approves an amended plan in the planning path. When `links` shows `next` naming `okstra discussion complete-amendment`, run it to move that follow-up's brief to the revision; otherwise show the user what `next` says is missing. Never approve a plan for the user
33
+ - Worker research procedure:
34
+ - research the subject independently and return evidence, assumptions and alternative directions; give each claim its evidence location and say what the evidence could not reach
35
+ - keep factual claims (what the code or an external source does) apart from questions only the user can answer (intent, scope, priority, acceptable risk)
36
+ - for each alternative direction, state what it requires, what it excludes and the risk it carries, without ranking it into a decision the user has not made
37
+ - Census aspects:
38
+ - task subject-framing: What exactly is the subject and the question to settle, and where does the request leave it ambiguous?
39
+ - task factual-claims: Which claims about the current code or external facts bear on the decision, and what evidence establishes or refutes each?
40
+ - task alternative-directions: Which alternative directions exist, and what does each require, exclude and risk?
41
+ - task user-decisions: Which questions can only the user answer, and what does each answer change?
42
+ - Cross-verification mode:
43
+ - Phase 5.5 convergence runs in **adversarial mode** for factual claims. Verifiers try to refute each claim by re-inspecting the cited evidence. A convergence stop ends one verification cycle; it does not end the discussion.
44
+ - A participant checking its own claim is not an independent refutation.
45
+ - Non-goals:
46
+ - **source code edits, detailed implementation plans, builds, or deployments** — the follow-up task starts at `requirements-discovery` and plans the work there
47
+ - starting a follow-up task's run, or editing its plan or source, inside this run
48
+ - **writes outside `<PROJECT_ROOT>/.okstra/`**
@@ -0,0 +1,73 @@
1
+ """토론 실행 보고서 — 확정본 참조 블록과 그 검사, 표시 모델.
2
+
3
+ 확정본이 정본이다. 보고서의 `discussion` 블록은 조립이 토론 기록에서 만들고 확정본 id 와
4
+ 내용 digest 만 싣는다. 보고서 작성자는 이 블록을 쓰지 않는다.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ from pathlib import Path
10
+ from typing import Any, Mapping
11
+
12
+ from okstra_ctl.report_html.common import evidence_index
13
+ from okstra_ctl.report_html.models import HumanReportView
14
+
15
+ from .store import (
16
+ DiscussionStore,
17
+ DiscussionStoreError,
18
+ parse_revision_id,
19
+ read_draft,
20
+ read_revision,
21
+ same_task,
22
+ )
23
+ from .validation import revision_errors
24
+
25
+ REPORT_BLOCK = "discussion"
26
+
27
+
28
+ def discussion_report_block(project_root: Path, task_key: str) -> dict[str, str]:
29
+ """보고서를 조립하는 시점의 토론 상태 — 완료면 확정본 참조, 아니면 초안 버전."""
30
+ draft = read_draft(DiscussionStore(project_root, task_key))
31
+ if draft["completedRevision"] is None:
32
+ return {"outcome": "draft", "draftVersion": draft["recordsDigest"]}
33
+ latest = draft["revisions"][-1]
34
+ return {"outcome": "revision", "revisionId": latest["revisionId"], "contentDigest": latest["contentDigest"]}
35
+
36
+
37
+ def discussion_report_errors(data: Mapping[str, Any], project_root: Path) -> list[str]:
38
+ header = data.get("header") or {}
39
+ if header.get("taskType") != "discussion":
40
+ return []
41
+ block = data.get(REPORT_BLOCK)
42
+ if not isinstance(block, Mapping):
43
+ return ["discussion: the report has no `discussion` block"]
44
+ if block.get("outcome") != "revision":
45
+ return []
46
+ try:
47
+ task_key, _ = parse_revision_id(str(block.get("revisionId") or ""))
48
+ if not same_task(task_key, str(header.get("taskKey") or "")):
49
+ return [f"discussion: {block['revisionId']} is not a revision of {header.get('taskKey')}"]
50
+ publication, content = read_revision(DiscussionStore(project_root, task_key), block["revisionId"])
51
+ except DiscussionStoreError as exc:
52
+ return [f"discussion: {exc}"]
53
+ errors = []
54
+ if block.get("contentDigest") != publication["contentDigest"]:
55
+ errors.append(
56
+ f"discussion: the report names digest {block.get('contentDigest')} for {block['revisionId']}, "
57
+ f"but the published revision is {publication['contentDigest']}"
58
+ )
59
+ errors += [f"discussion: {block['revisionId']}: {error}" for error in revision_errors(json.loads(content))]
60
+ return errors
61
+
62
+
63
+ def build_discussion_view(data: dict) -> HumanReportView:
64
+ return HumanReportView(
65
+ "discussion",
66
+ "html/tasks/discussion.template.html",
67
+ {
68
+ "humanSummary": data["humanSummary"],
69
+ "discussion": data.get(REPORT_BLOCK) or {},
70
+ "evidenceIndex": evidence_index(data, ()),
71
+ },
72
+ (),
73
+ )
@@ -0,0 +1,15 @@
1
+ {% extends "html/base.template.html" %}
2
+ {% from "html/macros/layout.html" import action_list %}
3
+
4
+ {% block human_content %}
5
+ <section data-report-section="discussion-outcome">
6
+ <h2>{{ t('tasks.discussion.outcome') }}</h2>
7
+ <p>{{ humanSummary.outcome | inline_code }}</p>
8
+ {% if discussion.outcome == 'revision' %}
9
+ <p data-discussion-revision="{{ discussion.revisionId }}">{{ t('tasks.discussion.revision') }}: <code>{{ discussion.revisionId }}</code> (<code>{{ discussion.contentDigest }}</code>)</p>
10
+ {% else %}
11
+ <p data-discussion-draft="{{ discussion.draftVersion }}">{{ t('tasks.discussion.draft') }}</p>
12
+ {% endif %}
13
+ {{ action_list(humanSummary.actions) }}
14
+ </section>
15
+ {% endblock %}
@@ -0,0 +1,6 @@
1
+ {% import "md/macros/sections.md" as s with context -%}
2
+ {{ s.section("discussion", "Discussion Revision") }}
3
+ {#
4
+ The block references the canonical revision by id and content digest only;
5
+ `okstra discussion revision-show` prints the revision itself.
6
+ -#}