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,876 @@
1
+ """후속 작업 — 확정본으로 후속 브리프를 만들고, 연결된 작업의 영향 검토와 선택 적용을 기록한다.
2
+
3
+ 후속 작업이 어느 확정본을 쓰는지의 정본은 그 작업의 브리프 frontmatter 다. prepare 가 그 값을
4
+ run 입력 기록(`discussionRevisionRef`)으로 옮긴다. 토론 쪽 `links.jsonl` 은 대상의 위치와 적용
5
+ 기록만 갖고, 상태는 조회할 때마다 대상에서 다시 읽는다 — 두 프로젝트를 한 번의 쓰기로 묶을 수
6
+ 없으므로 대상 쓰기를 먼저 하고 연결 기록을 뒤에 남긴다.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import hashlib
11
+ import json
12
+ import os
13
+ from contextlib import contextmanager
14
+ from dataclasses import dataclass
15
+ from pathlib import Path
16
+ from types import ModuleType
17
+ from typing import Any, Callable, Iterator, Mapping, Optional
18
+
19
+ from okstra_ctl.brief_frontmatter import read_brief_frontmatter
20
+ from okstra_ctl.ids import slugify_task_segment
21
+ from okstra_ctl.json_boundary import JsonBoundaryError, load_owned_object
22
+ from okstra_ctl.manager_split import _fence, _load_brief_validator, _validate_rendered, brief_file_title
23
+ from okstra_ctl.manager_store import ManagerError
24
+ from okstra_ctl.paths import RunRef
25
+ from okstra_ctl.plan_approval import plan_is_approved
26
+ from okstra_ctl.plan_run_root import implementation_planning_report_seq, list_implementation_planning_reports
27
+ from okstra_ctl.run_context import dir_flock
28
+
29
+ from .store import (
30
+ _LOCK_FILENAME,
31
+ COMMON,
32
+ USER,
33
+ DiscussionStore,
34
+ DiscussionStoreError,
35
+ _complete_lines,
36
+ _now_iso,
37
+ _split_task_key,
38
+ drop_incomplete_line,
39
+ project_draft,
40
+ read_draft,
41
+ read_revision,
42
+ same_task,
43
+ )
44
+
45
+ SCHEMA_VERSION = "1.0"
46
+ GENERATOR = "okstra-discussion"
47
+ SOURCE_TYPE = "discussion"
48
+ RECOMMENDED_PHASE = "requirements-discovery"
49
+ END_STATE_SECTIONS = (("EB", "Expected Behavior"), ("PB", "Preserved Behavior"), ("EO", "Expected Outcome"))
50
+ CHOICES = ("adopt", "keep")
51
+ NONE = "_(none)_"
52
+
53
+ FM_TASK = "discussion-task"
54
+ FM_REVISION = "discussion-revision"
55
+ FM_DIGEST = "discussion-content-digest"
56
+ FM_FOLLOW_UP = "discussion-follow-up"
57
+ FM_REQUIREMENTS = "discussion-requirements"
58
+
59
+ _PREFIX = {"link": "L", "apply": "Y", "amendment": "Z"}
60
+ _IDENTITY_FIELDS = {
61
+ "link": ("targetTaskKey", "revisionId", "followUp", "briefSha256"),
62
+ "apply": ("targetTaskKey", "revisionId", "choice"),
63
+ "amendment": ("targetTaskKey", "revisionId"),
64
+ }
65
+ ROUTED = "routed-to-plan-amendment"
66
+
67
+
68
+ class FollowUpError(DiscussionStoreError):
69
+ pass
70
+
71
+
72
+ @dataclass(frozen=True)
73
+ class EndState:
74
+ requirement_id: str
75
+ kind: str
76
+ verify: str
77
+
78
+
79
+ @dataclass(frozen=True)
80
+ class Target:
81
+ task_key: str
82
+ project_root: Path
83
+
84
+ @property
85
+ def parts(self) -> tuple[str, str, str]:
86
+ return _split_task_key(self.task_key)
87
+
88
+ def brief_path(self, label: str) -> Path:
89
+ _, group, task_id = self.parts
90
+ title = brief_file_title(label)
91
+ stem = slugify_task_segment(task_id) + (f"-{title}" if title else "")
92
+ return self.project_root / ".okstra" / "briefs" / slugify_task_segment(group) / f"{stem}.md"
93
+
94
+
95
+ def parse_end_state(value: str) -> EndState:
96
+ head, separator, rest = value.partition("=")
97
+ kind, _, verify = rest.partition(":")
98
+ kind = kind.strip().upper()
99
+ if not separator or not head.strip() or kind not in {code for code, _ in END_STATE_SECTIONS}:
100
+ raise FollowUpError(f"--end-state {value!r} must be <N-NNNN>=<EB|PB|EO>[:<observation method>]")
101
+ return EndState(head.strip(), kind, " ".join(verify.split()))
102
+
103
+
104
+ def target_of(task_key: str, project_root: Path) -> Target:
105
+ _split_task_key(task_key)
106
+ return Target(task_key, Path(project_root).resolve())
107
+
108
+
109
+ def _norm(text: Any) -> str:
110
+ return " ".join(str(text or "").split())
111
+
112
+
113
+ def _sha(data: bytes) -> str:
114
+ return "sha256:" + hashlib.sha256(data).hexdigest()
115
+
116
+
117
+ def _validator() -> ModuleType:
118
+ try:
119
+ return _load_brief_validator()
120
+ except ManagerError as exc:
121
+ raise FollowUpError(str(exc)) from exc
122
+
123
+
124
+ def follow_up_labels(revision: Mapping[str, Any]) -> list[str]:
125
+ return sorted({row["appliesTo"] for row in revision["requirements"]} - {COMMON})
126
+
127
+
128
+ def _own_requirements(revision: Mapping[str, Any], label: str) -> list[dict[str, Any]]:
129
+ return [row for row in revision["requirements"] if row["appliesTo"] in (COMMON, label)]
130
+
131
+
132
+ def brief_revision_ref(path: Path) -> Optional[dict[str, Any]]:
133
+ """브리프가 고정한 확정본 — 없으면 토론의 후속 브리프가 아니다."""
134
+ frontmatter = read_brief_frontmatter(Path(path))
135
+ if not frontmatter.get(FM_REVISION):
136
+ return None
137
+ return {
138
+ "discussionTaskKey": frontmatter.get(FM_TASK, ""),
139
+ "revisionId": frontmatter[FM_REVISION],
140
+ "contentDigest": frontmatter.get(FM_DIGEST, ""),
141
+ "followUp": frontmatter.get(FM_FOLLOW_UP, ""),
142
+ "requirementIds": frontmatter.get(FM_REQUIREMENTS, "").split(),
143
+ }
144
+
145
+
146
+ # ---- 브리프 ---------------------------------------------------------------
147
+
148
+
149
+ def _end_state_items(
150
+ revision: Mapping[str, Any], own: list[dict[str, Any]], mapping: Mapping[str, EndState], verify_re: Any,
151
+ ) -> tuple[dict[str, list[str]], list[dict[str, Any]], list[dict[str, Any]]]:
152
+ """확정본 요구사항 → 끝 상태 항목. 관찰 방법을 이 변환이 더하면 새 의미라 사용자 확인 대상이다."""
153
+ items: dict[str, list[str]] = {code: [] for code, _ in END_STATE_SECTIONS}
154
+ rows: list[dict[str, Any]] = []
155
+ unmapped: list[dict[str, Any]] = []
156
+ for requirement in own:
157
+ end_state = mapping.get(requirement["recordId"])
158
+ if end_state is None:
159
+ unmapped.append(requirement)
160
+ continue
161
+ text = _norm(requirement["text"])
162
+ carries = bool(verify_re.search(text))
163
+ if carries and end_state.verify:
164
+ raise FollowUpError(
165
+ f"{requirement['recordId']} already carries an observation method in {revision['revisionId']}; "
166
+ f"pass --end-state {requirement['recordId']}={end_state.kind} without one"
167
+ )
168
+ if not carries and not end_state.verify:
169
+ raise FollowUpError(
170
+ f"{requirement['recordId']} has no observation method in {revision['revisionId']}; pass "
171
+ f"--end-state {requirement['recordId']}={end_state.kind}:<how to observe it> — the brief then "
172
+ "asks the user to confirm it"
173
+ )
174
+ item_id = f"{end_state.kind}-{len(items[end_state.kind]) + 1:03d}"
175
+ line = f"{item_id} ({requirement['recordId']} · {requirement['appliesTo']}) {text}"
176
+ items[end_state.kind].append(line if carries else f"{line} — verify: {end_state.verify}")
177
+ rows.append({
178
+ "itemId": item_id, "requirementId": requirement["recordId"], "text": requirement["text"],
179
+ "kind": end_state.kind, "verify": end_state.verify, "addedByConversion": not carries,
180
+ })
181
+ return items, rows, unmapped
182
+
183
+
184
+ def _bullets(lines: list[str]) -> list[str]:
185
+ return [f"- {line}" for line in lines] if lines else [NONE]
186
+
187
+
188
+ def _confirmations(revision: Mapping[str, Any]) -> list[str]:
189
+ """토론에서 사용자가 이미 한 확인 — 각 항목은 확정본 안의 원 기록을 출처로 단다."""
190
+ rid = revision["revisionId"]
191
+ subject, confirmation = revision["subject"], revision["confirmation"]
192
+ entries = [
193
+ ("subject and question", subject["recordId"],
194
+ [f"Subject: {subject['subject']}", f"Question: {subject['question']}"]),
195
+ ("direction and accepted risks", confirmation["recordId"],
196
+ [f"Direction: {revision['direction']}", f"Risks: {confirmation['riskAcknowledgement']}"]),
197
+ ]
198
+ entries += [(f"decision {row['recordId']}", row["recordId"], [row["text"]])
199
+ for row in revision["decisions"] if row["author"] == USER]
200
+ entries += [(f"answer to {question['recordId']}", answer["recordId"], [answer["text"]])
201
+ for question in revision["answeredQuestions"] for answer in question["answers"]
202
+ if answer["author"] == USER]
203
+ lines = [f"Confirmations the user already gave in the discussion. Each names its record in `{rid}`.", ""]
204
+ for number, (topic, record_id, quoted) in enumerate(entries, start=1):
205
+ lines += [f"### RC-{number} — {topic}", f"- source: `{rid}` {record_id}", "- answer (verbatim):", ""]
206
+ lines += [f" > {line}".rstrip() for text in quoted for line in str(text).splitlines() or [""]]
207
+ lines.append("")
208
+ return lines
209
+
210
+
211
+ def render_brief(
212
+ *, discussion_key: str, revision: Mapping[str, Any], data: bytes, publication: Mapping[str, Any],
213
+ label: str, target: Target, end_states: list[EndState], validator: ModuleType,
214
+ ) -> tuple[str, dict[str, Any]]:
215
+ rid = revision["revisionId"]
216
+ own = _own_requirements(revision, label)
217
+ if not own:
218
+ raise FollowUpError(
219
+ f"{rid} has no requirement for follow-up {label!r} (common, or applies to {label!r}); "
220
+ f"its follow-up labels are: {', '.join(follow_up_labels(revision)) or 'none'}"
221
+ )
222
+ by_id = {row["recordId"]: row for row in own}
223
+ mapping: dict[str, EndState] = {}
224
+ for end_state in end_states:
225
+ if end_state.requirement_id not in by_id:
226
+ raise FollowUpError(f"--end-state names {end_state.requirement_id}, which is not a requirement "
227
+ f"of follow-up {label!r} in {rid}")
228
+ if end_state.requirement_id in mapping:
229
+ raise FollowUpError(f"--end-state names {end_state.requirement_id} twice")
230
+ mapping[end_state.requirement_id] = end_state
231
+ items, end_rows, unmapped = _end_state_items(revision, own, mapping, validator._VERIFY_RE)
232
+ added = [row for row in end_rows if row["addedByConversion"]]
233
+ _, group, task_id = target.parts
234
+ path = target.brief_path(label)
235
+ created = revision["confirmation"]["confirmedAt"][:10]
236
+ confirmation = revision["confirmation"]["recordId"]
237
+ open_questions = [
238
+ f"intent-check: {row['itemId']} gives {row['requirementId']} the observation method "
239
+ f"\"{row['verify']}\", which {rid} does not carry — confirm with reporter"
240
+ for row in added
241
+ ]
242
+ open_questions += [
243
+ f"general: {row['recordId']} \"{_norm(row['text'])}\" has no end state in this brief yet — map it "
244
+ "before implementation-planning"
245
+ for row in unmapped
246
+ ]
247
+ open_questions += [
248
+ f"general: {row['recordId']} \"{_norm(row['summary'])}\" stays {row['standing']} after the discussion; "
249
+ f"the user accepted the remaining risk in {confirmation}"
250
+ for row in revision["disagreements"]
251
+ ]
252
+ open_questions += [f"general: unverified {row['kind']} {row['recordId']} — {_norm(row['detail'])}"
253
+ for row in revision["unverified"]]
254
+ constraints = [f"out of scope: {_norm(row['text'])} — excluded by the user in {row['recordId']} of {rid}"
255
+ for row in revision["scope"]["excluded"]]
256
+ constraints += [
257
+ f"out of scope: {_norm(row['text'])} — belongs to follow-up `{row['appliesTo']}` ({row['recordId']})"
258
+ for row in revision["requirements"] if row["appliesTo"] not in (COMMON, label)
259
+ ]
260
+ artifacts = [f"`{rid}` (`{publication['contentDigest']}`) — print it with "
261
+ f"`okstra discussion revision-show {discussion_key} --revision {rid}`"]
262
+ if revision.get("previousRevision"):
263
+ previous = revision["previousRevision"]
264
+ artifacts.append(f"`{previous['revisionId']}` — the revision {rid} reopened: {_norm(previous['changeReason'])}")
265
+ fence = _fence(data.decode("utf-8"))
266
+ lines = [
267
+ "---",
268
+ "type: brief",
269
+ f"brief-id: {path.stem}",
270
+ f"task-id: {task_id}",
271
+ "parent-id: self",
272
+ 'ticket-id: ""',
273
+ f"source-type: {SOURCE_TYPE}",
274
+ f"task-group: {group}",
275
+ "depth: 0",
276
+ f"created: {created}",
277
+ f"generator: {GENERATOR}",
278
+ f"reporter-confirmations: {'pending' if added else 'complete'}",
279
+ f"{FM_TASK}: {discussion_key}",
280
+ f"{FM_REVISION}: {rid}",
281
+ f"{FM_DIGEST}: {publication['contentDigest']}",
282
+ f"{FM_FOLLOW_UP}: {label}",
283
+ f"{FM_REQUIREMENTS}: {' '.join(row['recordId'] for row in own)}",
284
+ "---",
285
+ "",
286
+ f"# Task Brief: {group}/{path.stem}",
287
+ "",
288
+ f"> - Generated: {GENERATOR} · {created}",
289
+ f"> - Source type: {SOURCE_TYPE}",
290
+ f"> - Discussion revision: `{rid}` (follow-up `{label}`)",
291
+ f"> - Recommended next phase: {RECOMMENDED_PHASE}",
292
+ "> - Handoff contract: see `prompts/profiles/_common-contract.md` § \"Brief handoff contract\"",
293
+ "",
294
+ "## Source Material",
295
+ "",
296
+ f"### Source 1 — {SOURCE_TYPE}",
297
+ "",
298
+ f"- ref: {rid}",
299
+ "- fetched-via: okstra discussion revision-show",
300
+ f"- fetched-at: {revision['confirmation']['confirmedAt']}",
301
+ "- format: as-is",
302
+ "",
303
+ fence + "json",
304
+ data.decode("utf-8").rstrip("\n"),
305
+ fence,
306
+ "",
307
+ "## Context",
308
+ "",
309
+ f"The user confirmed this subject and question in `{discussion_key}` ({revision['subject']['recordId']}).",
310
+ "",
311
+ f"- Subject: {_norm(revision['subject']['subject'])}",
312
+ f"- Question: {_norm(revision['subject']['question'])}",
313
+ "",
314
+ f"In scope, as the user confirmed it in `{rid}`:",
315
+ "",
316
+ *_bullets([f"{_norm(row['text'])} ({row['recordId']})" for row in revision["scope"]["inScope"]]),
317
+ "",
318
+ "## Problem / Symptom",
319
+ "",
320
+ f"The discussion settled the question \"{_norm(revision['subject']['question'])}\". This follow-up "
321
+ f"carries the requirements of `{rid}` that are common or assigned to `{label}`.",
322
+ "",
323
+ "## Desired Outcome",
324
+ "",
325
+ f"{_norm(revision['direction'])} (the user's chosen direction, {confirmation} of `{rid}`)",
326
+ "",
327
+ ]
328
+ for code, heading in END_STATE_SECTIONS:
329
+ lines += [f"## {heading}", "", *_bullets(items[code]), ""]
330
+ lines += [
331
+ "## External Gates", "", NONE, "",
332
+ "## Constraints", "", *_bullets(constraints), "",
333
+ "## Related Artifacts", "", *_bullets(artifacts), "",
334
+ "## Related Task Graph", "",
335
+ "| From | Relation | To | Direction | Source | Impact |",
336
+ "|---|---|---|---|---|---|",
337
+ f"| {target.task_key} | follow-up-of | {discussion_key} | directed | okstra discussion follow-up-brief "
338
+ f"| uses {rid}; a later revision needs the user's impact review before this task adopts it |",
339
+ "",
340
+ "## Open Questions", "", *_bullets(open_questions), "",
341
+ "## Reporter Confirmations", "", *_confirmations(revision),
342
+ "## Augmentation", "",
343
+ "### Intent inferences", "",
344
+ *_bullets([f"intent-inference: {row['requirementId']} → observation method \"{row['verify']}\" "
345
+ f"for {row['itemId']}" for row in added]),
346
+ "",
347
+ ]
348
+ text = "\n".join(lines)
349
+ errors = _validate_rendered([{"briefPath": path, "text": text, "taskKey": target.task_key}], validator,
350
+ slugify_task_segment(group))
351
+ if errors:
352
+ raise FollowUpError("the follow-up brief failed validate-brief:\n- " + "\n- ".join(errors))
353
+ return text, {
354
+ "requirementIds": [row["recordId"] for row in own],
355
+ "endStates": end_rows,
356
+ "reporterConfirmations": "pending" if added else "complete",
357
+ "needsReporterConfirmation": [row["itemId"] for row in added],
358
+ }
359
+
360
+
361
+ def _write_brief(path: Path, text: str, *, written_sha: str, replace: bool) -> tuple[str, str]:
362
+ """대상 프로젝트의 브리프를 쓴다. 이 명령이 마지막으로 쓴 바이트가 아니면 `--replace` 없이는 덮지 않는다."""
363
+ data = text.encode("utf-8")
364
+ previous = ""
365
+ if path.is_file():
366
+ current = path.read_bytes()
367
+ previous = _sha(current)
368
+ if current == data:
369
+ return "unchanged", previous
370
+ if previous != written_sha and not replace:
371
+ raise FollowUpError(
372
+ f"{path} already holds a brief this command did not write last ({previous}); "
373
+ "it may carry the user's confirmations — pass --replace to replace it"
374
+ )
375
+ path.parent.mkdir(parents=True, exist_ok=True)
376
+ temp = path.with_name(f".{path.name}.{os.getpid()}.tmp")
377
+ try:
378
+ temp.write_bytes(data)
379
+ os.replace(temp, path)
380
+ finally:
381
+ temp.unlink(missing_ok=True)
382
+ return ("replaced" if previous else "created"), previous
383
+
384
+
385
+ # ---- 연결 기록 -------------------------------------------------------------
386
+
387
+
388
+ def _read_links(store: DiscussionStore) -> list[dict[str, Any]]:
389
+ path = store.links_path
390
+ raw = _complete_lines(path.read_bytes()) if path.is_file() else b""
391
+ rows = []
392
+ for number, line in enumerate(raw.decode("utf-8").splitlines(), start=1):
393
+ if not line.strip():
394
+ continue
395
+ try:
396
+ row = json.loads(line)
397
+ except json.JSONDecodeError as exc:
398
+ raise FollowUpError(f"{path}:{number} is not a JSON record ({exc.msg}); the link list is damaged") from exc
399
+ if not isinstance(row, dict) or row.get("kind") not in _PREFIX:
400
+ raise FollowUpError(f"{path}:{number} is not a follow-up link record")
401
+ rows.append(row)
402
+ return rows
403
+
404
+
405
+ @contextmanager
406
+ def _links_transaction(store: DiscussionStore) -> Iterator[list[dict[str, Any]]]:
407
+ with dir_flock(store.dir, _LOCK_FILENAME):
408
+ drop_incomplete_line(store.links_path)
409
+ yield _read_links(store)
410
+
411
+
412
+ def _identity_key(kind: str, fields: Mapping[str, Any]) -> str:
413
+ identity = {"kind": kind, **{name: fields.get(name, "") for name in _IDENTITY_FIELDS[kind]}}
414
+ return _sha(json.dumps(identity, ensure_ascii=False, sort_keys=True).encode("utf-8"))
415
+
416
+
417
+ def _existing(rows: list[dict[str, Any]], kind: str, fields: Mapping[str, Any]) -> Optional[dict[str, Any]]:
418
+ key = _identity_key(kind, fields)
419
+ return next((row for row in rows if row.get("idempotencyKey") == key), None)
420
+
421
+
422
+ def _append(store: DiscussionStore, rows: list[dict[str, Any]], kind: str,
423
+ fields: dict[str, Any]) -> tuple[dict[str, Any], bool]:
424
+ existing = _existing(rows, kind, fields)
425
+ if existing is not None:
426
+ return existing, False
427
+ record = {
428
+ "schemaVersion": SCHEMA_VERSION,
429
+ "taskKey": store.task_key,
430
+ "recordId": f"{_PREFIX[kind]}-{1 + sum(1 for row in rows if row['kind'] == kind):04d}",
431
+ "kind": kind,
432
+ "author": USER,
433
+ **fields,
434
+ "idempotencyKey": _identity_key(kind, fields),
435
+ "recordedAt": _now_iso(),
436
+ }
437
+ line = json.dumps(record, ensure_ascii=False, separators=(",", ":")) + "\n"
438
+ with store.links_path.open("ab") as handle:
439
+ handle.write(line.encode("utf-8"))
440
+ handle.flush()
441
+ os.fsync(handle.fileno())
442
+ rows.append(record)
443
+ return record, True
444
+
445
+
446
+ def _latest_link(rows: list[dict[str, Any]], target_key: str) -> Optional[dict[str, Any]]:
447
+ return next((row for row in reversed(rows)
448
+ if row["kind"] == "link" and same_task(row["targetTaskKey"], target_key)), None)
449
+
450
+
451
+ def _link_fields(target: Target, label: str, publication: Mapping[str, Any], path: Path,
452
+ text: str, meta: Mapping[str, Any]) -> dict[str, Any]:
453
+ return {
454
+ "followUp": label,
455
+ "targetTaskKey": target.task_key,
456
+ "targetProjectRoot": str(target.project_root),
457
+ "briefPath": path.relative_to(target.project_root).as_posix(),
458
+ "revisionId": publication["revisionId"],
459
+ "contentDigest": publication["contentDigest"],
460
+ "requirementIds": list(meta["requirementIds"]),
461
+ "endStates": list(meta["endStates"]),
462
+ "briefSha256": _sha(text.encode("utf-8")),
463
+ }
464
+
465
+
466
+ def _load_revision(store: DiscussionStore, revision_id: str) -> tuple[dict[str, Any], bytes, dict[str, Any]]:
467
+ publication, data = read_revision(store, revision_id)
468
+ return publication, data, json.loads(data)
469
+
470
+
471
+ def create_follow_up(
472
+ store: DiscussionStore, *, revision_id: str, label: str, target: Target,
473
+ end_states: list[EndState], replace: bool = False,
474
+ ) -> dict[str, Any]:
475
+ """확정본 하나로 후속 브리프를 쓰고 연결을 기록한다. 대상 쓰기가 먼저다 — 그 뒤 중단되면 재전송이 연결만 남긴다."""
476
+ if not label.strip() or label == COMMON or "|" in label:
477
+ raise FollowUpError(f"--follow-up {label!r} must name one follow-up (not `{COMMON}`, no '|')")
478
+ validator = _validator()
479
+ publication, data, revision = _load_revision(store, revision_id)
480
+ path = target.brief_path(label)
481
+ with _links_transaction(store) as rows:
482
+ latest = _latest_link(rows, target.task_key)
483
+ pinned = brief_revision_ref(path) if path.is_file() else None
484
+ for followed in (latest and latest["revisionId"], pinned and pinned["revisionId"]):
485
+ if followed and followed != revision_id:
486
+ raise FollowUpError(
487
+ f"{target.task_key} already follows {followed}; a follow-up never moves to another revision "
488
+ f"by itself — adopt {revision_id} with `okstra discussion apply {store.task_key} --revision "
489
+ f"{revision_id} --target-task {target.task_key} --choice adopt`"
490
+ )
491
+ if latest is not None and latest["followUp"] != label:
492
+ raise FollowUpError(f"{target.task_key} is already the follow-up `{latest['followUp']}` of this discussion")
493
+ text, meta = render_brief(discussion_key=store.task_key, revision=revision, data=data,
494
+ publication=publication, label=label, target=target, end_states=end_states,
495
+ validator=validator)
496
+ status, previous = _write_brief(path, text, written_sha=latest["briefSha256"] if latest else "",
497
+ replace=replace)
498
+ record, created = _append(store, rows, "link", _link_fields(target, label, publication, path, text, meta))
499
+ pending = meta["reporterConfirmations"] == "pending"
500
+ return {
501
+ "created": created,
502
+ "link": record,
503
+ "briefPath": str(path),
504
+ "briefStatus": status,
505
+ "previousBriefSha256": previous,
506
+ "recommendedPhase": RECOMMENDED_PHASE,
507
+ **meta,
508
+ "next": (
509
+ "The conversion added observation methods the discussion did not carry: confirm the intent-check "
510
+ "rows with the user (okstra-brief-gen Step 6.5) before the first run; prepare refuses a pending brief."
511
+ if pending else
512
+ f"Start the follow-up at {RECOMMENDED_PHASE} with --task-type {RECOMMENDED_PHASE} --task-brief {path}."
513
+ ),
514
+ }
515
+
516
+
517
+ # ---- 변경 내용과 영향 검토 -------------------------------------------------
518
+
519
+
520
+ def _delta(old: list[Mapping[str, Any]], new: list[Mapping[str, Any]],
521
+ key: Callable[[Mapping[str, Any]], Any], *fields: str) -> dict[str, list[dict[str, Any]]]:
522
+ before, after = {key(row): row for row in old}, {key(row): row for row in new}
523
+
524
+ def view(row: Mapping[str, Any]) -> dict[str, Any]:
525
+ return {"recordId": row.get("recordId"), **{name: row.get(name) for name in fields}}
526
+
527
+ return {
528
+ "added": [view(row) for k, row in after.items() if k not in before],
529
+ "removed": [view(row) for k, row in before.items() if k not in after],
530
+ }
531
+
532
+
533
+ def _item_delta(old: list[Mapping[str, Any]], new: list[Mapping[str, Any]],
534
+ key: Callable[[Mapping[str, Any]], Any], withdrawals: Mapping[str, Mapping[str, Any]],
535
+ *fields: str) -> dict[str, list[dict[str, Any]]]:
536
+ """요구사항·범위의 변화 — 빠진 항목에는 새 확정본이 기록한 사용자의 철회 사유를, 남은 항목은 `carried` 로 싣는다."""
537
+ delta = _delta(old, new, key, *fields)
538
+ before = {key(row) for row in old}
539
+ delta["removed"] = [
540
+ {**row, "withdrawal": withdrawals.get(row["recordId"])} for row in delta["removed"]
541
+ ]
542
+ delta["carried"] = [
543
+ {"recordId": row.get("recordId"), **{name: row.get(name) for name in fields}}
544
+ for row in new if key(row) in before
545
+ ]
546
+ return delta
547
+
548
+
549
+ def revision_changes(old: Mapping[str, Any], new: Mapping[str, Any], label: str = "") -> dict[str, Any]:
550
+ """두 확정본 사이에 바뀐 것. `label` 이 있으면 그 후속 작업의 요구사항(공통 포함)만 본다."""
551
+ def requirements(revision: Mapping[str, Any]) -> list[Mapping[str, Any]]:
552
+ return _own_requirements(revision, label) if label else revision["requirements"]
553
+
554
+ def text(row: Mapping[str, Any]) -> str:
555
+ return _norm(row.get("text"))
556
+
557
+ withdrawals = {row["recordId"]: row["withdrawal"]
558
+ for row in (new.get("itemChanges") or {}).get("withdrawn", [])}
559
+ changes: dict[str, Any] = {
560
+ "from": old["revisionId"],
561
+ "to": new["revisionId"],
562
+ "followUp": label or None,
563
+ "changeReason": (new.get("previousRevision") or {}).get("changeReason"),
564
+ "requirements": _item_delta(requirements(old), requirements(new),
565
+ lambda row: (row["appliesTo"], text(row)), withdrawals, "appliesTo", "text"),
566
+ "inScope": _item_delta(old["scope"]["inScope"], new["scope"]["inScope"], text, withdrawals, "text"),
567
+ "excluded": _item_delta(old["scope"]["excluded"], new["scope"]["excluded"], text, withdrawals, "text"),
568
+ "decisions": _delta(old["decisions"], new["decisions"], text, "text"),
569
+ "disagreements": _delta(old["disagreements"], new["disagreements"],
570
+ lambda row: _norm(row.get("summary")), "summary", "standing"),
571
+ "unverified": _delta(old["unverified"], new["unverified"],
572
+ lambda row: (row.get("kind"), _norm(row.get("detail"))), "kind", "detail"),
573
+ "direction": (
574
+ {"from": old["direction"], "to": new["direction"]}
575
+ if _norm(old["direction"]) != _norm(new["direction"]) else None
576
+ ),
577
+ }
578
+ changes["changed"] = changes["direction"] is not None or any(
579
+ part["added"] or part["removed"]
580
+ for part in changes.values()
581
+ if isinstance(part, dict) and "added" in part
582
+ )
583
+ return changes
584
+
585
+
586
+ def impact(store: DiscussionStore, *, from_revision: str, to_revision: str, label: str = "") -> dict[str, Any]:
587
+ _, _, old = _load_revision(store, from_revision)
588
+ _, _, new = _load_revision(store, to_revision)
589
+ return revision_changes(old, new, label)
590
+
591
+
592
+ def _planning_ref(target: Target) -> RunRef:
593
+ _, group, task_id = target.parts
594
+ return RunRef(project_root=target.project_root, task_group=group, task_id=task_id,
595
+ task_type="implementation-planning")
596
+
597
+
598
+ def _plan_reports(target: Target) -> list[Path]:
599
+ reports_dir = _planning_ref(target).reports_dir
600
+ return list_implementation_planning_reports(reports_dir) if reports_dir.is_dir() else []
601
+
602
+
603
+ def _plan_state(target: Target) -> tuple[bool, Optional[str]]:
604
+ """대상 task 에 계획(완료된 planning 보고서, 또는 준비된 planning run)이 있는가, 그 계획이 쓴 확정본."""
605
+ ref = _planning_ref(target)
606
+ reports = _plan_reports(target)
607
+ manifests = ref.run_dir / "manifests"
608
+ inputs = sorted(manifests.glob("run-inputs-*.json")) if manifests.is_dir() else []
609
+ basis = None
610
+ if inputs:
611
+ payload = load_owned_object(inputs[-1], artifact="run inputs")
612
+ basis = ((payload.get("inputs") or {}).get("discussionRevisionRef") or {}).get("revisionId")
613
+ return bool(reports or inputs), basis
614
+
615
+
616
+ def _amended_plan(target: Target, routed: Mapping[str, Any]) -> tuple[Optional[Path], str]:
617
+ """계획 수정 경로로 보낸 뒤 사용자가 승인한 planning 보고서 — 없으면 무엇이 없는지.
618
+
619
+ 근거는 보고서의 `frontmatter.approved` 다. 구현 run 이 받는 계획도 승인된 계획뿐이라
620
+ (`run._validate_approved_plan`), 승인 전의 새 보고서로 고정을 옮기면 브리프와 실제로 따르는 계획이 갈린다.
621
+ """
622
+ anchor = int(routed["planReportSeq"])
623
+ newer = [report for report in _plan_reports(target) if implementation_planning_report_seq(report) > anchor]
624
+ for report in newer:
625
+ if plan_is_approved(load_owned_object(report, artifact="implementation-planning report")):
626
+ return report, ""
627
+ seen = ", ".join(f"{report.name} (not approved)" for report in newer) or "none"
628
+ return None, (
629
+ f"no implementation-planning report of {target.task_key} newer than run {anchor:03d} — the latest when "
630
+ f"{routed['recordId']} routed the adoption — has `approved: true`; newer reports: {seen}. Request the "
631
+ "plan revision, re-run implementation-planning and let the user approve the amended plan first"
632
+ )
633
+
634
+
635
+ def _applies(rows: list[dict[str, Any]], kind: str = "apply") -> dict[tuple[str, str], dict[str, Any]]:
636
+ return {(row["revisionId"], row["targetTaskKey"].lower()): row for row in rows if row["kind"] == kind}
637
+
638
+
639
+ def _target_view(
640
+ store: DiscussionStore, link: Mapping[str, Any], publications: Mapping[str, Mapping[str, Any]],
641
+ latest: Optional[str], applies: Mapping[tuple[str, str], Mapping[str, Any]],
642
+ amendments: Mapping[tuple[str, str], Mapping[str, Any]], revisions: Callable[[str], dict[str, Any]],
643
+ ) -> dict[str, Any]:
644
+ view: dict[str, Any] = {
645
+ "followUp": link["followUp"], "targetTaskKey": link["targetTaskKey"],
646
+ "targetProjectRoot": link["targetProjectRoot"], "briefPath": link["briefPath"],
647
+ "linkedRevision": link["revisionId"], "latestRevision": latest,
648
+ }
649
+ target = Target(link["targetTaskKey"], Path(link["targetProjectRoot"]))
650
+ brief = target.project_root / link["briefPath"]
651
+ try:
652
+ ref = brief_revision_ref(brief) if brief.is_file() else None
653
+ if ref is None:
654
+ return {**view, "state": "unknown",
655
+ "reason": f"{brief} is missing or names no discussion revision; the target cannot be read"}
656
+ publication = publications.get(ref["revisionId"])
657
+ if not same_task(ref["discussionTaskKey"], store.task_key) or publication is None \
658
+ or publication["contentDigest"] != ref["contentDigest"]:
659
+ return {**view, "state": "unknown",
660
+ "reason": f"{brief} pins {ref['revisionId']} ({ref['contentDigest']}), which is not a "
661
+ "published revision of this discussion"}
662
+ has_plan, basis = _plan_state(target)
663
+ except (OSError, JsonBoundaryError) as exc:
664
+ return {**view, "state": "unknown", "reason": f"the target cannot be read: {exc}"}
665
+ view.update({"pinnedRevision": ref["revisionId"], "requirementIds": ref["requirementIds"],
666
+ "hasPlan": has_plan, "planBasis": basis})
667
+ amendment = amendments.get((ref["revisionId"], link["targetTaskKey"].lower()))
668
+ if amendment is not None:
669
+ view["amendmentRecord"] = amendment["recordId"]
670
+ if latest is None or ref["revisionId"] == latest:
671
+ return {**view, "state": "current"}
672
+ decision = applies.get((latest, link["targetTaskKey"].lower()))
673
+ state = ("needs-impact-review" if decision is None
674
+ else "kept" if decision["choice"] == "keep" else "amendment-pending")
675
+ if state == "amendment-pending" and decision.get("result") == ROUTED:
676
+ try:
677
+ plan, missing = _amended_plan(target, decision)
678
+ except (OSError, JsonBoundaryError) as exc:
679
+ return {**view, "state": "unknown", "reason": f"the target's plan cannot be read: {exc}"}
680
+ view["amendedPlan"] = plan.relative_to(target.project_root).as_posix() if plan else None
681
+ view["next"] = (
682
+ f"okstra discussion complete-amendment {store.task_key} --revision {latest} "
683
+ f"--target-task {link['targetTaskKey']}" if plan else missing
684
+ )
685
+ changes = revision_changes(revisions(ref["revisionId"]), revisions(latest), link["followUp"])
686
+ return {**view, "state": state, "applyRecord": decision["recordId"] if decision else None, "changes": changes}
687
+
688
+
689
+ def links(store: DiscussionStore) -> dict[str, Any]:
690
+ """연결된 후속 작업과 영향 검토 상태 — 매번 대상의 브리프에서 다시 읽고, 어디에도 쓰지 않는다."""
691
+ draft = project_draft(store)
692
+ publications = {row["revisionId"]: row for row in draft["revisions"]}
693
+ latest = draft["revisions"][-1]["revisionId"] if draft["revisions"] else None
694
+ cache: dict[str, dict[str, Any]] = {}
695
+
696
+ def revision(revision_id: str) -> dict[str, Any]:
697
+ if revision_id not in cache:
698
+ cache[revision_id] = _load_revision(store, revision_id)[2]
699
+ return cache[revision_id]
700
+
701
+ rows = _read_links(store)
702
+ targets: dict[str, dict[str, Any]] = {}
703
+ for row in rows:
704
+ if row["kind"] == "link":
705
+ targets[row["targetTaskKey"].lower()] = row
706
+ applies = _applies(rows)
707
+ amendments = _applies(rows, "amendment")
708
+ views = [_target_view(store, link, publications, latest, applies, amendments, revision)
709
+ for link in targets.values()]
710
+ labels = follow_up_labels(revision(latest)) if latest else []
711
+ return {
712
+ "latestRevision": latest,
713
+ "followUps": views,
714
+ "unlinkedFollowUps": [label for label in labels if label not in {v["followUp"] for v in views}],
715
+ }
716
+
717
+
718
+ # ---- 적용 ------------------------------------------------------------------
719
+
720
+
721
+ def _amendment_route(store: DiscussionStore, target: Target, previous: str, revision_id: str,
722
+ label: str) -> dict[str, Any]:
723
+ return {
724
+ "path": "plan-amendment",
725
+ "reason": f"{target.task_key} already has an implementation-planning plan; applying {revision_id} never "
726
+ "rewrites an approved plan, its brief or its source",
727
+ "steps": [
728
+ f"okstra discussion impact {store.task_key} --from {previous} --to {revision_id} --follow-up {label}",
729
+ "record the change as a `revision-requested` plan decision on the target's latest "
730
+ "implementation-planning report (okstra-user-response, `okstra user-response plan-decision "
731
+ "--status revision-requested`)",
732
+ "re-run implementation-planning for the target with --clarification-response <that sidecar>",
733
+ "after the user approves the amended plan, move the brief to the revision with "
734
+ f"`okstra discussion complete-amendment {store.task_key} --revision {revision_id} "
735
+ f"--target-task {target.task_key}`",
736
+ ],
737
+ }
738
+
739
+
740
+ def _rewrite_brief(
741
+ store: DiscussionStore, rows: list[dict[str, Any]], link: Mapping[str, Any], target: Target, path: Path,
742
+ publication: Mapping[str, Any], data: bytes, revision: Mapping[str, Any], end_states: list[EndState],
743
+ replace: bool,
744
+ ) -> dict[str, Any]:
745
+ """대상 브리프를 새 확정본으로 다시 쓰고 연결을 남긴다. 끝 상태는 요구사항 문장으로 이어받고 `--end-state` 가 더한다."""
746
+ reused = {_norm(row["text"]): row for row in link.get("endStates") or []}
747
+ explicit = {row.requirement_id: row for row in end_states}
748
+ own = _own_requirements(revision, link["followUp"])
749
+ unknown = sorted(set(explicit) - {row["recordId"] for row in own})
750
+ if unknown:
751
+ raise FollowUpError(f"--end-state names {', '.join(unknown)}, which is not a requirement of "
752
+ f"follow-up {link['followUp']!r} in {revision['revisionId']}")
753
+ mapped = []
754
+ for requirement in own:
755
+ row = explicit.get(requirement["recordId"]) or reused.get(_norm(requirement["text"]))
756
+ if isinstance(row, EndState):
757
+ mapped.append(row)
758
+ elif row is not None:
759
+ mapped.append(EndState(requirement["recordId"], row["kind"], row["verify"]))
760
+ text, meta = render_brief(discussion_key=store.task_key, revision=revision, data=data,
761
+ publication=publication, label=link["followUp"], target=target,
762
+ end_states=mapped, validator=_validator())
763
+ status, previous_sha = _write_brief(path, text, written_sha=link["briefSha256"], replace=replace)
764
+ new_link, _ = _append(store, rows, "link", _link_fields(target, link["followUp"], publication, path, text, meta))
765
+ return {"briefStatus": status, "previousBriefSha256": previous_sha, "link": new_link["recordId"],
766
+ "reporterConfirmations": meta["reporterConfirmations"],
767
+ "needsReporterConfirmation": meta["needsReporterConfirmation"]}
768
+
769
+
770
+ def apply_revision(
771
+ store: DiscussionStore, *, revision_id: str, target_key: str, choice: str,
772
+ end_states: list[EndState], replace: bool = False,
773
+ ) -> dict[str, Any]:
774
+ """사용자가 고른 대상에만 새 확정본을 적용한다. 같은 요청의 재전송은 저장된 적용 기록을 돌려준다."""
775
+ if choice not in CHOICES:
776
+ raise FollowUpError(f"--choice must be one of {', '.join(CHOICES)}")
777
+ draft = read_draft(store)
778
+ latest = draft["revisions"][-1]["revisionId"] if draft["revisions"] else None
779
+ if revision_id != latest:
780
+ raise FollowUpError(f"apply names {revision_id}, but the latest published revision is {latest or 'none'}")
781
+ publication, data, revision = _load_revision(store, revision_id)
782
+ with _links_transaction(store) as rows:
783
+ fields_key = {"targetTaskKey": target_key, "revisionId": revision_id, "choice": choice}
784
+ existing = _existing(rows, "apply", fields_key)
785
+ if existing is not None:
786
+ return {"created": False, "apply": existing}
787
+ earlier = _applies(rows).get((revision_id, target_key.lower()))
788
+ if earlier is not None:
789
+ raise FollowUpError(f"{target_key} already has the user's choice {earlier['choice']!r} for "
790
+ f"{revision_id} ({earlier['recordId']})")
791
+ link = _latest_link(rows, target_key)
792
+ if link is None:
793
+ raise FollowUpError(f"no follow-up {target_key} is linked to {store.task_key}")
794
+ target = Target(link["targetTaskKey"], Path(link["targetProjectRoot"]))
795
+ path = target.project_root / link["briefPath"]
796
+ try:
797
+ pinned = brief_revision_ref(path) if path.is_file() else None
798
+ has_plan, _ = _plan_state(target)
799
+ reports = _plan_reports(target)
800
+ except (OSError, JsonBoundaryError) as exc:
801
+ raise FollowUpError(f"{target_key} cannot be read ({exc}); its state is unknown") from exc
802
+ if pinned is None:
803
+ raise FollowUpError(f"{path} is missing or names no revision; the state of {target_key} is unknown")
804
+ previous = pinned["revisionId"]
805
+ if previous == revision_id:
806
+ # 앞선 적용이 대상을 쓰고 기록 전에 끊긴 경우 — 이전 확정본은 연결 기록에 남아 있다.
807
+ before = next((row for row in reversed(rows) if row["kind"] == "link"
808
+ and same_task(row["targetTaskKey"], target_key) and row["revisionId"] != revision_id),
809
+ None)
810
+ if before is None or choice != "adopt":
811
+ raise FollowUpError(f"{target_key} already uses {revision_id}")
812
+ previous = before["revisionId"]
813
+ result: dict[str, Any] = {}
814
+ if choice == "keep":
815
+ outcome = "kept"
816
+ elif has_plan:
817
+ outcome = ROUTED
818
+ result["route"] = _amendment_route(store, target, previous, revision_id, link["followUp"])
819
+ result["planReport"] = reports[0].relative_to(target.project_root).as_posix() if reports else None
820
+ result["planReportSeq"] = implementation_planning_report_seq(reports[0]) if reports else 0
821
+ else:
822
+ outcome = "applied"
823
+ result.update(_rewrite_brief(store, rows, link, target, path, publication, data, revision,
824
+ end_states, replace))
825
+ result["recommendedPhase"] = RECOMMENDED_PHASE
826
+ record, created = _append(store, rows, "apply", {
827
+ **fields_key, "followUp": link["followUp"], "previousRevision": previous,
828
+ "result": outcome, **result,
829
+ })
830
+ return {"created": created, "apply": record}
831
+
832
+
833
+ def complete_amendment(
834
+ store: DiscussionStore, *, revision_id: str, target_key: str, end_states: list[EndState], replace: bool = False,
835
+ ) -> dict[str, Any]:
836
+ """계획 수정 경로로 보낸 채택을 끝낸다 — 그 뒤 사용자가 승인한 계획이 있을 때만 브리프 고정을 옮긴다.
837
+
838
+ 계획·소스는 읽기만 하고, 승인은 사용자가 계획 쪽 경로에서 한 것만 근거로 쓴다.
839
+ """
840
+ publication, data, revision = _load_revision(store, revision_id)
841
+ with _links_transaction(store) as rows:
842
+ fields_key = {"targetTaskKey": target_key, "revisionId": revision_id}
843
+ existing = _existing(rows, "amendment", fields_key)
844
+ if existing is not None:
845
+ return {"created": False, "amendment": existing}
846
+ routed = _applies(rows).get((revision_id, target_key.lower()))
847
+ if routed is None or routed.get("result") != ROUTED:
848
+ raise FollowUpError(
849
+ f"{target_key} has no adoption of {revision_id} routed to plan amendment; record the user's choice "
850
+ f"with `okstra discussion apply {store.task_key} --revision {revision_id} --target-task {target_key} "
851
+ "--choice adopt` first"
852
+ )
853
+ link = _latest_link(rows, target_key)
854
+ target = Target(link["targetTaskKey"], Path(link["targetProjectRoot"]))
855
+ path = target.project_root / link["briefPath"]
856
+ try:
857
+ pinned = brief_revision_ref(path) if path.is_file() else None
858
+ plan, missing = _amended_plan(target, routed)
859
+ except (OSError, JsonBoundaryError) as exc:
860
+ raise FollowUpError(f"{target_key} cannot be read ({exc}); its state is unknown") from exc
861
+ if pinned is None:
862
+ raise FollowUpError(f"{path} is missing or names no revision; the state of {target_key} is unknown")
863
+ if pinned["revisionId"] not in (routed["previousRevision"], revision_id):
864
+ raise FollowUpError(
865
+ f"{path} pins {pinned['revisionId']}, not {routed['previousRevision']} that {routed['recordId']} "
866
+ "routed from; the brief changed since the adoption was routed"
867
+ )
868
+ if plan is None:
869
+ raise FollowUpError(missing)
870
+ result = _rewrite_brief(store, rows, link, target, path, publication, data, revision, end_states, replace)
871
+ record, created = _append(store, rows, "amendment", {
872
+ **fields_key, "followUp": link["followUp"], "previousRevision": routed["previousRevision"],
873
+ "applyRecord": routed["recordId"], "planReport": plan.relative_to(target.project_root).as_posix(),
874
+ "planReportSeq": implementation_planning_report_seq(plan), "result": "applied", **result,
875
+ })
876
+ return {"created": created, "amendment": record}