okstra 0.214.1 → 0.216.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -1
- package/dist/cli-registry.d.mts +14 -0
- package/dist/cli-registry.mjs +12 -0
- package/dist/cli-registry.mjs.map +1 -1
- package/dist/commands/chat/chat.mjs +2 -56
- package/dist/commands/chat/chat.mjs.map +1 -1
- package/dist/commands/execute/render-bundle.mjs +5 -2
- package/dist/commands/execute/render-bundle.mjs.map +1 -1
- package/dist/commands/execute/wizard.mjs +6 -0
- package/dist/commands/execute/wizard.mjs.map +1 -1
- package/dist/commands/memory/memory.mjs +57 -6
- package/dist/commands/memory/memory.mjs.map +1 -1
- package/dist/lib/file-lock.d.mts +1 -0
- package/dist/lib/file-lock.mjs +60 -0
- package/dist/lib/file-lock.mjs.map +1 -0
- package/dist/lib/python-command.mjs +11 -1
- package/dist/lib/python-command.mjs.map +1 -1
- package/dist/lib/skill-catalog.mjs +1 -0
- package/dist/lib/skill-catalog.mjs.map +1 -1
- package/dist/lib/types.d.mts +1 -1
- package/docs/architecture.md +2 -2
- package/docs/cli.md +36 -14
- package/docs/project-structure-overview.md +6 -3
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/bin/lib/okstra/cli.sh +9 -2
- package/runtime/bin/lib/okstra/globals.sh +2 -0
- package/runtime/bin/lib/okstra/interactive.sh +8 -2
- package/runtime/bin/lib/okstra/usage.sh +3 -1
- package/runtime/bin/okstra.sh +7 -1
- package/runtime/prompts/duties/discussion-worker.json +32 -0
- package/runtime/prompts/lead/convergence.md +58 -136
- package/runtime/prompts/lead/okstra-lead-contract.md +45 -12
- package/runtime/prompts/profiles/_common-contract.md +1 -1
- package/runtime/prompts/wizard/prompts.ko.json +14 -1
- package/runtime/python/okstra_ctl/adapters/providers/antigravity/adapter.py +4 -0
- package/runtime/python/okstra_ctl/adapters/providers/claude/adapter.py +4 -11
- package/runtime/python/okstra_ctl/agent/invocation.py +1 -0
- package/runtime/python/okstra_ctl/agent/prompt_cli/dynamic_verifier.py +2 -2
- package/runtime/python/okstra_ctl/agent/prompt_cli/materialize.py +4 -4
- package/runtime/python/okstra_ctl/agent/prompt_cli/run_identity.py +2 -2
- package/runtime/python/okstra_ctl/analysis_packet.py +3 -0
- package/runtime/python/okstra_ctl/consumers.py +8 -2
- package/runtime/python/okstra_ctl/convergence.py +143 -29
- package/runtime/python/okstra_ctl/convergence_critic_prompt.py +57 -0
- package/runtime/python/okstra_ctl/convergence_critic_verify_prompt.py +106 -0
- package/runtime/python/okstra_ctl/convergence_engine.py +140 -95
- package/runtime/python/okstra_ctl/convergence_reverify_prompt.py +3 -27
- package/runtime/python/okstra_ctl/convergence_store.py +11 -4
- package/runtime/python/okstra_ctl/dispatch_core.py +4 -1
- package/runtime/python/okstra_ctl/domain/role.py +1 -0
- package/runtime/python/okstra_ctl/manager_cli.py +27 -1
- package/runtime/python/okstra_ctl/manager_launch.py +44 -4
- package/runtime/python/okstra_ctl/manager_sync.py +14 -1
- package/runtime/python/okstra_ctl/manager_view.py +53 -1
- package/runtime/python/okstra_ctl/next_phase.py +1 -0
- package/runtime/python/okstra_ctl/phases/catalog.py +2 -1
- package/runtime/python/okstra_ctl/phases/discussion/__init__.py +1 -0
- package/runtime/python/okstra_ctl/phases/discussion/boundary.json +11 -0
- package/runtime/python/okstra_ctl/phases/discussion/cli.py +512 -0
- package/runtime/python/okstra_ctl/phases/discussion/entry.py +116 -0
- package/runtime/python/okstra_ctl/phases/discussion/followup.py +876 -0
- package/runtime/python/okstra_ctl/phases/discussion/memory.py +294 -0
- package/runtime/python/okstra_ctl/phases/discussion/overview.py +85 -0
- package/runtime/python/okstra_ctl/phases/discussion/profile.json +31 -0
- package/runtime/python/okstra_ctl/phases/discussion/profile.md +48 -0
- package/runtime/python/okstra_ctl/phases/discussion/report.py +73 -0
- package/runtime/python/okstra_ctl/phases/discussion/report_assets/discussion.template.html +15 -0
- package/runtime/python/okstra_ctl/phases/discussion/report_assets/discussion.template.md +6 -0
- package/runtime/python/okstra_ctl/phases/discussion/spec.md +197 -0
- package/runtime/python/okstra_ctl/phases/discussion/store.py +1143 -0
- package/runtime/python/okstra_ctl/phases/discussion/validation.py +111 -0
- package/runtime/python/okstra_ctl/phases/discussion/wizard.py +90 -0
- package/runtime/python/okstra_ctl/phases/discussion/workflow.py +667 -0
- package/runtime/python/okstra_ctl/phases/implementation/instructions/_implementation-deliverable.md +1 -1
- package/runtime/python/okstra_ctl/phases/implementation_planning/host-rules.md +4 -1
- package/runtime/python/okstra_ctl/phases/implementation_planning/instructions/plan-body-verification.md +1 -1
- package/runtime/python/okstra_ctl/phases/requirements_discovery/spec.md +1 -1
- package/runtime/python/okstra_ctl/plan_run_root.py +8 -0
- package/runtime/python/okstra_ctl/render.py +7 -20
- package/runtime/python/okstra_ctl/render_final_report.py +2 -1
- package/runtime/python/okstra_ctl/report_assembly.py +22 -0
- package/runtime/python/okstra_ctl/report_contract.py +2 -0
- package/runtime/python/okstra_ctl/report_narrative.py +13 -4
- package/runtime/python/okstra_ctl/report_synthesis_packet.py +10 -0
- package/runtime/python/okstra_ctl/run.py +40 -7
- package/runtime/python/okstra_ctl/stage_close.py +46 -11
- package/runtime/python/okstra_ctl/stage_reopen.py +151 -0
- package/runtime/python/okstra_ctl/verdict_blocks.py +11 -35
- package/runtime/python/okstra_ctl/wizard/cli.py +8 -0
- package/runtime/python/okstra_ctl/wizard/confirmation.py +4 -1
- package/runtime/python/okstra_ctl/wizard/engine.py +8 -0
- package/runtime/python/okstra_ctl/wizard/ids.py +12 -0
- package/runtime/python/okstra_ctl/wizard/registry.py +35 -3
- package/runtime/python/okstra_ctl/wizard/render.py +4 -1
- package/runtime/python/okstra_ctl/wizard/sources.py +2 -2
- package/runtime/python/okstra_ctl/wizard/state.py +11 -1
- package/runtime/python/okstra_ctl/wizard/steps_identity.py +4 -4
- package/runtime/python/okstra_ctl/worker_prompt_policy.py +24 -6
- package/runtime/python/okstra_token_usage/pricing.py +2 -0
- package/runtime/schemas/convergence-groups-v1.0.schema.json +1 -1
- package/runtime/schemas/convergence-groups-v2.0.schema.json +1 -1
- package/runtime/schemas/final-report-v3.0.schema.json +90 -1
- package/runtime/skills/_fragments/wizard-bash-invocation-rule.md +1 -0
- package/runtime/skills/_fragments/wizard-confirm-step.md +7 -0
- package/runtime/skills/_fragments/wizard-lead-takeover.md +8 -0
- package/runtime/skills/_fragments/wizard-preflight.md +75 -0
- package/runtime/skills/_fragments/wizard-prompt-loop.md +22 -0
- package/runtime/skills/_fragments/wizard-relay-protocol.md +40 -0
- package/runtime/skills/_fragments/wizard-render-bundle.md +74 -0
- package/runtime/skills/okstra-discussion/SKILL.md +347 -0
- package/runtime/skills/okstra-pr-gen/SKILL.md +7 -0
- package/runtime/skills/okstra-run/SKILL.md +22 -2
- package/runtime/templates/manager/view.template.html +3 -0
- package/runtime/templates/reports/html/i18n/en.json +5 -0
- package/runtime/templates/reports/html/i18n/ko.json +5 -0
- package/runtime/validators/validate-brief.py +3 -2
- package/runtime/validators/validate-run.py +4 -6
|
@@ -0,0 +1,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}
|