okstra 0.201.3 → 0.202.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/dist/commands/lifecycle/setup.mjs +15 -0
- package/dist/commands/lifecycle/setup.mjs.map +1 -1
- package/dist/lib/citation-guidance.d.mts +21 -0
- package/dist/lib/citation-guidance.mjs +79 -0
- package/dist/lib/citation-guidance.mjs.map +1 -0
- package/docs/architecture/storage-model.md +4 -0
- package/docs/architecture.md +1 -1
- package/docs/cli.md +2 -2
- package/docs/for-ai/skills/okstra-manager.md +21 -4
- package/docs/for-ai/skills/okstra-setup.md +9 -0
- package/docs/project-structure-overview.md +4 -1
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/prompts/launch.template.md +1 -1
- package/runtime/prompts/lead/okstra-lead-contract.md +2 -2
- package/runtime/prompts/profiles/_implementation-verifier.md +1 -1
- package/runtime/prompts/profiles/implementation-planning.md +2 -0
- package/runtime/python/okstra_ctl/convergence_critic_prompt.py +4 -6
- package/runtime/python/okstra_ctl/convergence_provenance.py +75 -18
- package/runtime/python/okstra_ctl/execution_mutation_audit.py +21 -21
- package/runtime/python/okstra_ctl/manager_cli.py +85 -12
- package/runtime/python/okstra_ctl/manager_launch.py +40 -18
- package/runtime/python/okstra_ctl/manager_paths.py +8 -0
- package/runtime/python/okstra_ctl/manager_split.py +474 -0
- package/runtime/python/okstra_ctl/manager_store.py +121 -18
- package/runtime/python/okstra_ctl/manager_sync.py +33 -15
- package/runtime/python/okstra_ctl/manager_view.py +216 -0
- package/runtime/python/okstra_ctl/plan_items_cli.py +6 -1
- package/runtime/python/okstra_ctl/qa_commands.py +15 -0
- package/runtime/python/okstra_ctl/report_finalize.py +13 -6
- package/runtime/python/okstra_ctl/report_html/view_models/final_verification.py +2 -21
- package/runtime/python/okstra_ctl/report_html/visualizations.py +0 -5
- package/runtime/python/okstra_ctl/verification_target.py +13 -2
- package/runtime/skills/okstra-manager/SKILL.md +53 -4
- package/runtime/skills/okstra-run/SKILL.md +1 -1
- package/runtime/skills/okstra-setup/SKILL.md +9 -0
- package/runtime/templates/manager/view.template.html +108 -0
- package/runtime/templates/reports/html/i18n/en.json +2 -0
- package/runtime/templates/reports/html/i18n/ko.json +2 -0
- package/runtime/templates/reports/html/tasks/final-verification.template.html +2 -2
- package/runtime/validators/validate-brief.py +7 -2
|
@@ -0,0 +1,474 @@
|
|
|
1
|
+
"""트래커 분할 계획을 받아 프로젝트별 범위 지정 브리프를 쓰고 manager 하위 태스크로 등록한다.
|
|
2
|
+
|
|
3
|
+
Linear 조회와 본문 저작은 에이전트(okstra-manager 스킬)가 한다. 이 모듈은 계획 검증,
|
|
4
|
+
브리프 렌더링, 브리프 검증기 통과 확인, 파일 쓰기, 하위 태스크 등록을 맡는다. 검증이
|
|
5
|
+
하나라도 실패하면 브리프도 manager 상태도 쓰지 않는다.
|
|
6
|
+
|
|
7
|
+
브리프 계약(EB/PB/EO 형식, Open Questions 접두어, 관계 표 값)은 여기서 다시 구현하지
|
|
8
|
+
않는다. 렌더링 결과를 `validators/validate-brief.py` 로 검사해서 집행한다.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import importlib.util
|
|
13
|
+
import re
|
|
14
|
+
import tempfile
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from types import ModuleType
|
|
17
|
+
|
|
18
|
+
from okstra_ctl.json_boundary import JsonBoundaryError, load_owned_object
|
|
19
|
+
from okstra_ctl.jsonl import append_jsonl
|
|
20
|
+
|
|
21
|
+
from .ids import slugify_task_segment
|
|
22
|
+
from .manager_paths import children_json_path, events_jsonl_path, split_plan_path, task_manifest_path
|
|
23
|
+
from .manager_store import (
|
|
24
|
+
ManagerError,
|
|
25
|
+
_now_iso,
|
|
26
|
+
_read_json,
|
|
27
|
+
_write_json,
|
|
28
|
+
create_task,
|
|
29
|
+
list_projects,
|
|
30
|
+
load_manager,
|
|
31
|
+
)
|
|
32
|
+
from .paths import BRIEF_VALIDATOR_RELATIVE, find_asset_root
|
|
33
|
+
|
|
34
|
+
PLAN_SCHEMA_VERSION = 1
|
|
35
|
+
SOURCE_KINDS = ("project", "issue")
|
|
36
|
+
RECOMMENDED_PHASES = ("requirements-discovery", "error-analysis", "improvement-discovery")
|
|
37
|
+
DEFAULT_PHASE = "requirements-discovery"
|
|
38
|
+
GENERATOR = "okstra-manager"
|
|
39
|
+
SOURCE_TYPE = "linear"
|
|
40
|
+
# 브리프 계약의 필수 서술 필드. 비어 있으면 검증기가 거절하므로 계획 단계에서 먼저 막는다.
|
|
41
|
+
_REQUIRED_TEXT_FIELDS = ("context", "problem", "desiredOutcome")
|
|
42
|
+
_LIST_FIELDS = (
|
|
43
|
+
"expectedBehavior",
|
|
44
|
+
"preservedBehavior",
|
|
45
|
+
"expectedOutcome",
|
|
46
|
+
"externalGates",
|
|
47
|
+
"constraints",
|
|
48
|
+
"openQuestions",
|
|
49
|
+
"outOfScope",
|
|
50
|
+
)
|
|
51
|
+
# 파일명·하위 task id 로 쓰이므로 경로 구분자나 공백을 받지 않는다.
|
|
52
|
+
_TICKET_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$")
|
|
53
|
+
# okstra-brief-gen Step 2b: 영숫자·공백·한글만 남긴다.
|
|
54
|
+
_FILE_TITLE_DROP = re.compile(r"[^0-9A-Za-z가-힣ㄱ-ㅎㅏ-ㅣ ]+")
|
|
55
|
+
_FILE_TITLE_MAX = 60
|
|
56
|
+
_NONE = "_(none)_"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def brief_file_title(title: str) -> str:
|
|
60
|
+
"""okstra-brief-gen Step 2b 의 `<file-title>` 규칙."""
|
|
61
|
+
slug = re.sub(r" +", "-", _FILE_TITLE_DROP.sub("", title).strip()).lower()
|
|
62
|
+
if len(slug) > _FILE_TITLE_MAX:
|
|
63
|
+
cut = slug[:_FILE_TITLE_MAX]
|
|
64
|
+
boundary = cut.rfind("-")
|
|
65
|
+
slug = cut[:boundary] if boundary > 0 else cut
|
|
66
|
+
return slug.strip("-")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _load_brief_validator() -> ModuleType:
|
|
70
|
+
root = find_asset_root(BRIEF_VALIDATOR_RELATIVE)
|
|
71
|
+
if root is None:
|
|
72
|
+
raise ManagerError("brief validator not found: " + "/".join(BRIEF_VALIDATOR_RELATIVE))
|
|
73
|
+
spec = importlib.util.spec_from_file_location("okstra_validate_brief", root.joinpath(*BRIEF_VALIDATOR_RELATIVE))
|
|
74
|
+
if spec is None or spec.loader is None:
|
|
75
|
+
raise ManagerError("brief validator could not be loaded")
|
|
76
|
+
module = importlib.util.module_from_spec(spec)
|
|
77
|
+
spec.loader.exec_module(module)
|
|
78
|
+
return module
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _load_plan(path: Path) -> dict:
|
|
82
|
+
try:
|
|
83
|
+
return load_owned_object(Path(path), artifact="manager split plan")
|
|
84
|
+
except JsonBoundaryError as exc:
|
|
85
|
+
raise ManagerError(str(exc)) from exc
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _text(value: object) -> str:
|
|
89
|
+
return value.strip() if isinstance(value, str) else ""
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _string_list(value: object, where: str, errors: list[str]) -> list[str]:
|
|
93
|
+
if value is None:
|
|
94
|
+
return []
|
|
95
|
+
if not isinstance(value, list):
|
|
96
|
+
errors.append(f"{where} must be a list of strings")
|
|
97
|
+
return []
|
|
98
|
+
items = []
|
|
99
|
+
for index, item in enumerate(value, 1):
|
|
100
|
+
text = _text(item)
|
|
101
|
+
if not text:
|
|
102
|
+
errors.append(f"{where}[{index}] must be a non-empty string")
|
|
103
|
+
continue
|
|
104
|
+
items.append(text)
|
|
105
|
+
return items
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _require_text(record: dict, key: str, where: str, errors: list[str]) -> str:
|
|
109
|
+
value = _text(record.get(key))
|
|
110
|
+
if not value:
|
|
111
|
+
errors.append(f"{where}.{key} is required")
|
|
112
|
+
return value
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _no_pipe(value: str, where: str, errors: list[str]) -> None:
|
|
116
|
+
# 관계 표의 셀 구분자라 값에 들어오면 표가 깨진다.
|
|
117
|
+
if "|" in value:
|
|
118
|
+
errors.append(f"{where} must not contain '|'")
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _normalize_source(raw: object, errors: list[str]) -> dict:
|
|
122
|
+
if not isinstance(raw, dict):
|
|
123
|
+
errors.append("source must be an object")
|
|
124
|
+
return {}
|
|
125
|
+
source = {key: _require_text(raw, key, "source", errors) for key in ("kind", "ref", "title", "body")}
|
|
126
|
+
if source["kind"] and source["kind"] not in SOURCE_KINDS:
|
|
127
|
+
errors.append(f"source.kind must be one of {list(SOURCE_KINDS)}, got {source['kind']!r}")
|
|
128
|
+
source["fetchedVia"] = _text(raw.get("fetchedVia")) or "user-paste"
|
|
129
|
+
source["fetchedAt"] = _text(raw.get("fetchedAt"))
|
|
130
|
+
_no_pipe(source["ref"], "source.ref", errors)
|
|
131
|
+
_no_pipe(source["title"], "source.title", errors)
|
|
132
|
+
return source
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _normalize_assignment(raw: object, where: str, members: set[str], errors: list[str]) -> dict:
|
|
136
|
+
if not isinstance(raw, dict):
|
|
137
|
+
errors.append(f"{where} must be an object")
|
|
138
|
+
return {}
|
|
139
|
+
assignment = {"projectId": _require_text(raw, "projectId", where, errors)}
|
|
140
|
+
if assignment["projectId"] and assignment["projectId"] not in members:
|
|
141
|
+
errors.append(f"{where}.projectId {assignment['projectId']!r} is not a project of this manager")
|
|
142
|
+
assignment["scope"] = _string_list(raw.get("scope"), f"{where}.scope", errors)
|
|
143
|
+
if not assignment["scope"]:
|
|
144
|
+
errors.append(f"{where}.scope must list at least one item of work for this project")
|
|
145
|
+
phase = _text(raw.get("recommendedPhase")) or DEFAULT_PHASE
|
|
146
|
+
if phase not in RECOMMENDED_PHASES:
|
|
147
|
+
errors.append(f"{where}.recommendedPhase must be one of {list(RECOMMENDED_PHASES)}, got {phase!r}")
|
|
148
|
+
assignment["recommendedPhase"] = phase
|
|
149
|
+
for key in _REQUIRED_TEXT_FIELDS:
|
|
150
|
+
assignment[key] = _require_text(raw, key, where, errors)
|
|
151
|
+
for key in _LIST_FIELDS:
|
|
152
|
+
assignment[key] = _string_list(raw.get(key), f"{where}.{key}", errors)
|
|
153
|
+
return assignment
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _normalize_relations(raw: object, where: str, relations: set[str], errors: list[str]) -> list[dict]:
|
|
157
|
+
if raw is None:
|
|
158
|
+
return []
|
|
159
|
+
if not isinstance(raw, list):
|
|
160
|
+
errors.append(f"{where}.relations must be a list")
|
|
161
|
+
return []
|
|
162
|
+
rows = []
|
|
163
|
+
for index, item in enumerate(raw, 1):
|
|
164
|
+
at = f"{where}.relations[{index}]"
|
|
165
|
+
if not isinstance(item, dict):
|
|
166
|
+
errors.append(f"{at} must be an object")
|
|
167
|
+
continue
|
|
168
|
+
row = {key: _require_text(item, key, at, errors) for key in ("relation", "to", "source", "impact")}
|
|
169
|
+
if row["relation"] and row["relation"] not in relations:
|
|
170
|
+
errors.append(f"{at}.relation must be one of {sorted(relations)}, got {row['relation']!r}")
|
|
171
|
+
for key, value in row.items():
|
|
172
|
+
_no_pipe(value, f"{at}.{key}", errors)
|
|
173
|
+
rows.append(row)
|
|
174
|
+
return rows
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _normalize_issue(raw: object, where: str, members: set[str], relations: set[str], errors: list[str]) -> dict:
|
|
178
|
+
if not isinstance(raw, dict):
|
|
179
|
+
errors.append(f"{where} must be an object")
|
|
180
|
+
return {}
|
|
181
|
+
issue = {key: _require_text(raw, key, where, errors) for key in ("ticketId", "title", "body")}
|
|
182
|
+
if issue["ticketId"] and not _TICKET_ID.match(issue["ticketId"]):
|
|
183
|
+
errors.append(f"{where}.ticketId {issue['ticketId']!r} must match {_TICKET_ID.pattern}")
|
|
184
|
+
issue["url"] = _text(raw.get("url"))
|
|
185
|
+
_no_pipe(issue["url"], f"{where}.url", errors)
|
|
186
|
+
issue["fetchedVia"] = _text(raw.get("fetchedVia")) or "user-paste"
|
|
187
|
+
issue["fetchedAt"] = _text(raw.get("fetchedAt"))
|
|
188
|
+
issue["relations"] = _normalize_relations(raw.get("relations"), where, relations, errors)
|
|
189
|
+
raw_assignments = raw.get("assignments")
|
|
190
|
+
if not isinstance(raw_assignments, list) or not raw_assignments:
|
|
191
|
+
errors.append(f"{where}.assignments must list at least one project")
|
|
192
|
+
raw_assignments = []
|
|
193
|
+
issue["assignments"] = [
|
|
194
|
+
_normalize_assignment(item, f"{where}.assignments[{index}]", members, errors)
|
|
195
|
+
for index, item in enumerate(raw_assignments, 1)
|
|
196
|
+
]
|
|
197
|
+
seen = [a.get("projectId") for a in issue["assignments"] if a.get("projectId")]
|
|
198
|
+
for project_id in sorted({pid for pid in seen if seen.count(pid) > 1}):
|
|
199
|
+
errors.append(f"{where}.assignments names project {project_id!r} more than once")
|
|
200
|
+
return issue
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def normalize_plan(plan: dict, members: set[str], relations: set[str]) -> tuple[dict, list[dict], list[str]]:
|
|
204
|
+
"""계획을 (source, issues, errors) 로 정리한다. errors 가 비어야 쓸 수 있다."""
|
|
205
|
+
errors: list[str] = []
|
|
206
|
+
if plan.get("schemaVersion") != PLAN_SCHEMA_VERSION:
|
|
207
|
+
errors.append(f"schemaVersion must be {PLAN_SCHEMA_VERSION}, got {plan.get('schemaVersion')!r}")
|
|
208
|
+
source = _normalize_source(plan.get("source"), errors)
|
|
209
|
+
raw_issues = plan.get("issues")
|
|
210
|
+
if not isinstance(raw_issues, list) or not raw_issues:
|
|
211
|
+
errors.append("issues must list at least one issue")
|
|
212
|
+
raw_issues = []
|
|
213
|
+
issues = [
|
|
214
|
+
_normalize_issue(item, f"issues[{index}]", members, relations, errors)
|
|
215
|
+
for index, item in enumerate(raw_issues, 1)
|
|
216
|
+
]
|
|
217
|
+
ticket_ids = [issue.get("ticketId") for issue in issues if issue.get("ticketId")]
|
|
218
|
+
for ticket_id in sorted({tid for tid in ticket_ids if ticket_ids.count(tid) > 1}):
|
|
219
|
+
errors.append(f"issues name ticketId {ticket_id!r} more than once")
|
|
220
|
+
return source, issues, errors
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _bullets(items: list[str]) -> list[str]:
|
|
224
|
+
return [f"- {item}" for item in items] if items else [_NONE]
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _fence(body: str) -> str:
|
|
228
|
+
longest = max((len(run) for run in re.findall(r"`+", body)), default=0)
|
|
229
|
+
return "`" * max(3, longest + 1)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _source_block(index: int, ref: str, fetched_via: str, fetched_at: str, body: str) -> list[str]:
|
|
233
|
+
fence = _fence(body)
|
|
234
|
+
return [
|
|
235
|
+
f"### Source {index} — {SOURCE_TYPE}",
|
|
236
|
+
"",
|
|
237
|
+
f"- ref: {ref}",
|
|
238
|
+
f"- fetched-via: {fetched_via}",
|
|
239
|
+
f"- fetched-at: {fetched_at or '-'}",
|
|
240
|
+
"- format: as-is",
|
|
241
|
+
"",
|
|
242
|
+
fence,
|
|
243
|
+
body,
|
|
244
|
+
fence,
|
|
245
|
+
"",
|
|
246
|
+
]
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _source_is_issue(source: dict, issue: dict) -> bool:
|
|
250
|
+
return source["ref"] in {issue["ticketId"], issue["url"]}
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _graph_rows(source: dict, issues: list[dict], keys: dict, directed: set[str]) -> list[tuple[str, ...]]:
|
|
254
|
+
"""분할 전체 위상. 모든 브리프에 같은 표가 들어간다(okstra-brief-gen 분할 규칙)."""
|
|
255
|
+
membership = "Linear project membership" if source["kind"] == "project" else "Linear sub-issue"
|
|
256
|
+
rows: list[tuple[str, ...]] = []
|
|
257
|
+
for issue in issues:
|
|
258
|
+
ticket = issue["ticketId"]
|
|
259
|
+
if not _source_is_issue(source, issue):
|
|
260
|
+
rows.append((source["ref"], "parent-of", ticket, "directed", membership,
|
|
261
|
+
f"{ticket} is part of {source['title']}"))
|
|
262
|
+
for assignment in issue["assignments"]:
|
|
263
|
+
rows.append((keys[(ticket, assignment["projectId"])], "split-from", ticket, "directed",
|
|
264
|
+
"okstra manager task split",
|
|
265
|
+
f"covers only the {assignment['projectId']} part of {ticket}"))
|
|
266
|
+
for relation in issue["relations"]:
|
|
267
|
+
direction = "directed" if relation["relation"] in directed else "undirected"
|
|
268
|
+
rows.append((ticket, relation["relation"], relation["to"], direction,
|
|
269
|
+
relation["source"], relation["impact"]))
|
|
270
|
+
return rows
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _render_brief(
|
|
274
|
+
*,
|
|
275
|
+
source: dict,
|
|
276
|
+
issue: dict,
|
|
277
|
+
assignment: dict,
|
|
278
|
+
task_group: str,
|
|
279
|
+
manager_task: str,
|
|
280
|
+
stem: str,
|
|
281
|
+
created: str,
|
|
282
|
+
key: str,
|
|
283
|
+
project_root: str,
|
|
284
|
+
siblings: list[tuple[str, dict, Path]],
|
|
285
|
+
graph: list[tuple[str, ...]],
|
|
286
|
+
) -> str:
|
|
287
|
+
ticket = issue["ticketId"]
|
|
288
|
+
project_id = assignment["projectId"]
|
|
289
|
+
out_of_scope = list(assignment["outOfScope"])
|
|
290
|
+
out_of_scope.extend(f"`{sibling_key}` (project `{other['projectId']}`): {'; '.join(other['scope'])}"
|
|
291
|
+
for sibling_key, other, _ in siblings)
|
|
292
|
+
lines = [
|
|
293
|
+
"---",
|
|
294
|
+
"type: brief",
|
|
295
|
+
f"brief-id: {stem}",
|
|
296
|
+
"parent-id: self",
|
|
297
|
+
f"ticket-id: {ticket}",
|
|
298
|
+
f"source-type: {SOURCE_TYPE}",
|
|
299
|
+
f"task-group: {task_group}",
|
|
300
|
+
"depth: 0",
|
|
301
|
+
f"created: {created}",
|
|
302
|
+
f"generator: {GENERATOR}",
|
|
303
|
+
"reporter-confirmations: skipped",
|
|
304
|
+
f"project-id: {project_id}",
|
|
305
|
+
f"manager-task: {manager_task}",
|
|
306
|
+
"---",
|
|
307
|
+
"",
|
|
308
|
+
f"# Task Brief: {task_group}/{stem}",
|
|
309
|
+
"",
|
|
310
|
+
f"> - Generated: {GENERATOR} · {created}",
|
|
311
|
+
f"> - Source type: {SOURCE_TYPE}",
|
|
312
|
+
f"> - Tracker key (if any): {ticket}",
|
|
313
|
+
f"> - Project: `{project_id}` (child task `{key}`)",
|
|
314
|
+
f"> - Recommended next phase: {assignment['recommendedPhase']}",
|
|
315
|
+
"> - Handoff contract: see `prompts/profiles/_common-contract.md` § \"Brief handoff contract\"",
|
|
316
|
+
"",
|
|
317
|
+
"## Source Material",
|
|
318
|
+
"",
|
|
319
|
+
*_source_block(1, issue["url"] or ticket, issue["fetchedVia"], issue["fetchedAt"], issue["body"]),
|
|
320
|
+
]
|
|
321
|
+
if not _source_is_issue(source, issue):
|
|
322
|
+
lines.extend(_source_block(2, source["ref"], source["fetchedVia"], source["fetchedAt"], source["body"]))
|
|
323
|
+
lines.extend(
|
|
324
|
+
[
|
|
325
|
+
"## Context",
|
|
326
|
+
"",
|
|
327
|
+
assignment["context"],
|
|
328
|
+
"",
|
|
329
|
+
"## Project Scope",
|
|
330
|
+
"",
|
|
331
|
+
f"This brief covers only the part of {ticket} that lands in project `{project_id}` "
|
|
332
|
+
f"(`{project_root}`). Work outside the in-scope list belongs to the tasks named under out of scope.",
|
|
333
|
+
"",
|
|
334
|
+
"### In scope",
|
|
335
|
+
"",
|
|
336
|
+
*_bullets(assignment["scope"]),
|
|
337
|
+
"",
|
|
338
|
+
"### Out of scope",
|
|
339
|
+
"",
|
|
340
|
+
*_bullets(out_of_scope),
|
|
341
|
+
"",
|
|
342
|
+
"## Problem / Symptom",
|
|
343
|
+
"",
|
|
344
|
+
assignment["problem"],
|
|
345
|
+
"",
|
|
346
|
+
"## Desired Outcome",
|
|
347
|
+
"",
|
|
348
|
+
assignment["desiredOutcome"],
|
|
349
|
+
"",
|
|
350
|
+
]
|
|
351
|
+
)
|
|
352
|
+
for heading, field in (("Expected Behavior", "expectedBehavior"), ("Preserved Behavior", "preservedBehavior"),
|
|
353
|
+
("Expected Outcome", "expectedOutcome"), ("External Gates", "externalGates"),
|
|
354
|
+
("Constraints", "constraints")):
|
|
355
|
+
lines.extend([f"## {heading}", "", *_bullets(assignment[field]), ""])
|
|
356
|
+
artifacts = [ref for ref in (issue["url"], source["ref"]) if ref]
|
|
357
|
+
artifacts.extend(f"`{sibling_key}` brief: {path}" for sibling_key, _, path in siblings)
|
|
358
|
+
lines.extend(["## Related Artifacts", "", *_bullets(artifacts), "", "## Related Task Graph", ""])
|
|
359
|
+
lines.append("| From | Relation | To | Direction | Source | Impact |")
|
|
360
|
+
lines.append("|---|---|---|---|---|---|")
|
|
361
|
+
lines.extend("| " + " | ".join(row) + " |" for row in graph)
|
|
362
|
+
lines.extend(
|
|
363
|
+
[
|
|
364
|
+
"",
|
|
365
|
+
"## Open Questions",
|
|
366
|
+
"",
|
|
367
|
+
*_bullets(assignment["openQuestions"]),
|
|
368
|
+
"",
|
|
369
|
+
"## Reporter Confirmations",
|
|
370
|
+
"",
|
|
371
|
+
"_(none — pending or skipped)_",
|
|
372
|
+
"",
|
|
373
|
+
]
|
|
374
|
+
)
|
|
375
|
+
return "\n".join(lines)
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def _validate_rendered(items: list[dict], validator: ModuleType, group_segment: str) -> list[str]:
|
|
379
|
+
errors: list[str] = []
|
|
380
|
+
with tempfile.TemporaryDirectory(prefix="okstra-split-") as scratch:
|
|
381
|
+
for index, item in enumerate(items):
|
|
382
|
+
briefs_root = Path(scratch) / str(index) / "briefs"
|
|
383
|
+
path = briefs_root / group_segment / item["briefPath"].name
|
|
384
|
+
path.parent.mkdir(parents=True)
|
|
385
|
+
path.write_text(item["text"], encoding="utf-8")
|
|
386
|
+
errors.extend(f"{item['taskKey']}: {error}" for error in validator.validate_brief(path, briefs_root))
|
|
387
|
+
return errors
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def split_task(
|
|
391
|
+
home: Path,
|
|
392
|
+
manager_id: str,
|
|
393
|
+
task_group: str,
|
|
394
|
+
task_id: str,
|
|
395
|
+
plan_path: Path,
|
|
396
|
+
*,
|
|
397
|
+
overwrite: bool = False,
|
|
398
|
+
now: str | None = None,
|
|
399
|
+
) -> dict:
|
|
400
|
+
"""분할 계획으로 브리프를 쓰고 하위 태스크를 등록한다. 결과 브리프 목록을 돌려준다."""
|
|
401
|
+
load_manager(home, manager_id)
|
|
402
|
+
projects = {str(project.get("projectId")): project for project in list_projects(home, manager_id)}
|
|
403
|
+
group_segment = slugify_task_segment(task_group)
|
|
404
|
+
if not group_segment:
|
|
405
|
+
raise ManagerError(f"task-group {task_group!r} has no usable slug")
|
|
406
|
+
plan = _load_plan(plan_path)
|
|
407
|
+
validator = _load_brief_validator()
|
|
408
|
+
source, issues, errors = normalize_plan(plan, set(projects), set(validator.TASK_RELATIONS))
|
|
409
|
+
if errors:
|
|
410
|
+
raise ManagerError("split plan rejected:\n- " + "\n- ".join(errors))
|
|
411
|
+
when = now or _now_iso()
|
|
412
|
+
manager_task = f"{manager_id}/{task_group}/{task_id}"
|
|
413
|
+
keys: dict[tuple[str, str], str] = {}
|
|
414
|
+
items: list[dict] = []
|
|
415
|
+
for issue in issues:
|
|
416
|
+
ticket = issue["ticketId"]
|
|
417
|
+
stem = "-".join(part for part in (ticket, brief_file_title(issue["title"])) if part)
|
|
418
|
+
for assignment in issue["assignments"]:
|
|
419
|
+
project_id = assignment["projectId"]
|
|
420
|
+
child_task_id = slugify_task_segment(ticket)
|
|
421
|
+
key = f"{project_id}:{task_group}:{child_task_id}"
|
|
422
|
+
keys[(ticket, project_id)] = key
|
|
423
|
+
root = Path(str(projects[project_id].get("projectRoot") or ""))
|
|
424
|
+
items.append({"issue": issue, "assignment": assignment, "stem": stem, "taskKey": key,
|
|
425
|
+
"projectId": project_id, "childTaskId": child_task_id, "projectRoot": str(root),
|
|
426
|
+
"briefPath": root / ".okstra" / "briefs" / group_segment / f"{stem}.md"})
|
|
427
|
+
graph = _graph_rows(source, issues, keys, set(validator.DIRECTED_TASK_RELATIONS))
|
|
428
|
+
for item in items:
|
|
429
|
+
siblings = [(other["taskKey"], other["assignment"], other["briefPath"]) for other in items
|
|
430
|
+
if other["issue"] is item["issue"] and other is not item]
|
|
431
|
+
item["text"] = _render_brief(source=source, issue=item["issue"], assignment=item["assignment"],
|
|
432
|
+
task_group=task_group, manager_task=manager_task, stem=item["stem"],
|
|
433
|
+
created=when[:10], key=item["taskKey"], project_root=item["projectRoot"],
|
|
434
|
+
siblings=siblings, graph=graph)
|
|
435
|
+
errors = _validate_rendered(items, validator, group_segment)
|
|
436
|
+
if errors:
|
|
437
|
+
raise ManagerError("rendered briefs failed validate-brief:\n- " + "\n- ".join(errors))
|
|
438
|
+
for item in items:
|
|
439
|
+
path = item["briefPath"]
|
|
440
|
+
if not path.is_file():
|
|
441
|
+
item["briefStatus"] = "created"
|
|
442
|
+
elif path.read_text(encoding="utf-8") == item["text"]:
|
|
443
|
+
item["briefStatus"] = "unchanged"
|
|
444
|
+
else:
|
|
445
|
+
item["briefStatus"] = "overwritten"
|
|
446
|
+
conflicts = [str(item["briefPath"]) for item in items if item["briefStatus"] == "overwritten"]
|
|
447
|
+
if conflicts and not overwrite:
|
|
448
|
+
raise ManagerError(
|
|
449
|
+
"briefs already exist with different content; pass --overwrite to replace them:\n- "
|
|
450
|
+
+ "\n- ".join(conflicts)
|
|
451
|
+
)
|
|
452
|
+
for item in items:
|
|
453
|
+
if item["briefStatus"] != "unchanged":
|
|
454
|
+
item["briefPath"].parent.mkdir(parents=True, exist_ok=True)
|
|
455
|
+
item["briefPath"].write_text(item["text"], encoding="utf-8")
|
|
456
|
+
existing = task_manifest_path(home, manager_id, task_group, task_id).is_file()
|
|
457
|
+
create_task(home, manager_id, task_group, task_id,
|
|
458
|
+
[{"projectId": item["projectId"], "taskId": item["childTaskId"]} for item in items],
|
|
459
|
+
objective="" if existing else source["title"], now=when)
|
|
460
|
+
children_path = children_json_path(home, manager_id, task_group, task_id)
|
|
461
|
+
children = _read_json(children_path)
|
|
462
|
+
by_key = {child.get("taskKey"): child for child in children.get("children", []) if isinstance(child, dict)}
|
|
463
|
+
for item in items:
|
|
464
|
+
by_key[item["taskKey"]].update({"ticketId": item["issue"]["ticketId"], "briefPath": str(item["briefPath"]),
|
|
465
|
+
"scope": list(item["assignment"]["scope"]),
|
|
466
|
+
"recommendedPhase": item["assignment"]["recommendedPhase"]})
|
|
467
|
+
_write_json(children_path, children)
|
|
468
|
+
_write_json(split_plan_path(home, manager_id, task_group, task_id), plan)
|
|
469
|
+
briefs = [{"taskKey": item["taskKey"], "projectId": item["projectId"], "ticketId": item["issue"]["ticketId"],
|
|
470
|
+
"briefPath": str(item["briefPath"]), "briefStatus": item["briefStatus"]} for item in items]
|
|
471
|
+
append_jsonl(events_jsonl_path(home, manager_id, task_group, task_id),
|
|
472
|
+
{"event": "task-split", "createdAt": when, "source": source["ref"],
|
|
473
|
+
"taskKeys": [brief["taskKey"] for brief in briefs]})
|
|
474
|
+
return {"managerId": manager_id, "taskGroup": task_group, "taskId": task_id, "briefs": briefs}
|
|
@@ -96,6 +96,63 @@ def load_manager(home: Path, manager_id: str) -> dict:
|
|
|
96
96
|
return _read_json(path)
|
|
97
97
|
|
|
98
98
|
|
|
99
|
+
def _task_manifest_paths(home: Path, manager_id: str) -> list[Path]:
|
|
100
|
+
return sorted(manager_root(home, manager_id).glob("task-groups/*/*/manifest.json"))
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def list_managers(home: Path) -> list[dict]:
|
|
104
|
+
"""이 홈의 manager 를 id 순으로, 소속 프로젝트 수와 manager task 수와 함께 돌려준다."""
|
|
105
|
+
base = Path(home) / "managers"
|
|
106
|
+
if not base.is_dir():
|
|
107
|
+
return []
|
|
108
|
+
items: list[dict] = []
|
|
109
|
+
for path in sorted(base.glob("*/manager.json")):
|
|
110
|
+
manager = _read_json(path)
|
|
111
|
+
manager_id = str(manager.get("managerId") or path.parent.name)
|
|
112
|
+
items.append(
|
|
113
|
+
{
|
|
114
|
+
"managerId": manager_id,
|
|
115
|
+
"createdAt": str(manager.get("createdAt") or ""),
|
|
116
|
+
"projectCount": len(_load_projects(home, manager_id).get("projects", [])),
|
|
117
|
+
"taskCount": len(_task_manifest_paths(home, manager_id)),
|
|
118
|
+
}
|
|
119
|
+
)
|
|
120
|
+
return items
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def list_projects(home: Path, manager_id: str) -> list[dict]:
|
|
124
|
+
"""manager 에 등록된 프로젝트를 등록 파일(projects.json) 순서 그대로 돌려준다."""
|
|
125
|
+
load_manager(home, manager_id)
|
|
126
|
+
return [
|
|
127
|
+
project
|
|
128
|
+
for project in _load_projects(home, manager_id).get("projects", [])
|
|
129
|
+
if isinstance(project, dict)
|
|
130
|
+
]
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def list_tasks(home: Path, manager_id: str) -> list[dict]:
|
|
134
|
+
"""manager task 를 task-group, task-id 순으로 하위 task 수와 함께 돌려준다.
|
|
135
|
+
|
|
136
|
+
디렉터리 이름은 slug 라 원래 입력값과 다를 수 있으므로 식별자는 manifest 에서 읽는다.
|
|
137
|
+
"""
|
|
138
|
+
load_manager(home, manager_id)
|
|
139
|
+
items: list[dict] = []
|
|
140
|
+
for path in _task_manifest_paths(home, manager_id):
|
|
141
|
+
manifest = _read_json(path)
|
|
142
|
+
children = _read_json_default(path.parent / "children.json", {"children": []})
|
|
143
|
+
items.append(
|
|
144
|
+
{
|
|
145
|
+
"taskGroup": str(manifest.get("taskGroup") or ""),
|
|
146
|
+
"taskId": str(manifest.get("taskId") or ""),
|
|
147
|
+
"objective": str(manifest.get("objective") or ""),
|
|
148
|
+
"progressMode": str(manifest.get("progressMode") or ""),
|
|
149
|
+
"createdAt": str(manifest.get("createdAt") or ""),
|
|
150
|
+
"childCount": len(children.get("children", [])),
|
|
151
|
+
}
|
|
152
|
+
)
|
|
153
|
+
return sorted(items, key=lambda item: (item["taskGroup"], item["taskId"]))
|
|
154
|
+
|
|
155
|
+
|
|
99
156
|
def discover_projects(home: Path) -> list[dict]:
|
|
100
157
|
base = Path(home) / "projects"
|
|
101
158
|
if not base.is_dir():
|
|
@@ -174,6 +231,17 @@ def create_task_group(home: Path, manager_id: str, task_group: str, *, now: str
|
|
|
174
231
|
return {"managerId": manager_id, "taskGroup": task_group, "createdAt": now or _now_iso()}
|
|
175
232
|
|
|
176
233
|
|
|
234
|
+
def _merge_manifest(existing: dict, requested: dict) -> None:
|
|
235
|
+
"""이미 있는 manager task 의 manifest 값을 `new task` 가 바꾸지 못하게 한다."""
|
|
236
|
+
for key, value in requested.items():
|
|
237
|
+
if value is None or value == "" or existing.get(key) == value:
|
|
238
|
+
continue
|
|
239
|
+
raise ManagerError(
|
|
240
|
+
f"manager task already exists with {key}={existing.get(key)!r}; "
|
|
241
|
+
f"refusing to change it to {value!r}"
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
|
|
177
245
|
def create_task(
|
|
178
246
|
home: Path,
|
|
179
247
|
manager_id: str,
|
|
@@ -183,32 +251,56 @@ def create_task(
|
|
|
183
251
|
*,
|
|
184
252
|
objective: str = "",
|
|
185
253
|
common_brief_path: str = "",
|
|
186
|
-
progress_mode: str =
|
|
254
|
+
progress_mode: str | None = None,
|
|
187
255
|
now: str | None = None,
|
|
188
256
|
) -> dict:
|
|
257
|
+
"""manager task 를 만들거나, 이미 있으면 새 하위 task 만 덧붙인다.
|
|
258
|
+
|
|
259
|
+
다시 실행해도 기존 하위 task 의 assignment·launch 상태는 지우지 않는다.
|
|
260
|
+
"""
|
|
261
|
+
load_manager(home, manager_id)
|
|
189
262
|
projects = _load_projects(home, manager_id)
|
|
190
263
|
when = now or _now_iso()
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
264
|
+
manifest_path = task_manifest_path(home, manager_id, task_group, task_id)
|
|
265
|
+
existing = manifest_path.is_file()
|
|
266
|
+
if existing:
|
|
267
|
+
manifest = _read_json(manifest_path)
|
|
268
|
+
_merge_manifest(
|
|
269
|
+
manifest,
|
|
270
|
+
{
|
|
271
|
+
"objective": objective,
|
|
272
|
+
"commonBriefPath": common_brief_path,
|
|
273
|
+
"progressMode": progress_mode,
|
|
274
|
+
},
|
|
275
|
+
)
|
|
276
|
+
else:
|
|
277
|
+
manifest = {
|
|
278
|
+
"managerId": manager_id,
|
|
279
|
+
"taskGroup": task_group,
|
|
280
|
+
"taskId": task_id,
|
|
281
|
+
"progressMode": progress_mode or "manual",
|
|
282
|
+
"commonBriefPath": common_brief_path,
|
|
283
|
+
"objective": objective,
|
|
284
|
+
"createdAt": when,
|
|
285
|
+
}
|
|
286
|
+
children = _load_children(home, manager_id, task_group, task_id)
|
|
287
|
+
known_keys = {child.get("taskKey") for child in children["children"] if isinstance(child, dict)}
|
|
288
|
+
added = []
|
|
201
289
|
for spec in child_specs:
|
|
202
290
|
project_id = str(spec.get("projectId") or "")
|
|
203
291
|
child_task_id = str(spec.get("taskId") or task_id)
|
|
204
292
|
if _project_entry(projects, project_id) is None:
|
|
205
293
|
raise ManagerError(f"unknown project membership: {project_id}")
|
|
206
|
-
|
|
294
|
+
task_key = _child_task_key(project_id, task_group, child_task_id)
|
|
295
|
+
if task_key in known_keys:
|
|
296
|
+
continue
|
|
297
|
+
known_keys.add(task_key)
|
|
298
|
+
added.append(
|
|
207
299
|
{
|
|
208
300
|
"projectId": project_id,
|
|
209
301
|
"taskGroup": task_group,
|
|
210
302
|
"taskId": child_task_id,
|
|
211
|
-
"taskKey":
|
|
303
|
+
"taskKey": task_key,
|
|
212
304
|
"role": "",
|
|
213
305
|
"tags": [],
|
|
214
306
|
"assignment": "",
|
|
@@ -219,14 +311,19 @@ def create_task(
|
|
|
219
311
|
},
|
|
220
312
|
}
|
|
221
313
|
)
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
314
|
+
children["children"].extend(added)
|
|
315
|
+
if not existing:
|
|
316
|
+
_write_json(manifest_path, manifest)
|
|
317
|
+
_write_json(children_json_path(home, manager_id, task_group, task_id), children)
|
|
225
318
|
append_jsonl(
|
|
226
319
|
events_jsonl_path(home, manager_id, task_group, task_id),
|
|
227
|
-
{
|
|
320
|
+
{
|
|
321
|
+
"event": "task-children-added" if existing else "task-created",
|
|
322
|
+
"createdAt": when,
|
|
323
|
+
"taskKeys": [child["taskKey"] for child in added],
|
|
324
|
+
},
|
|
228
325
|
)
|
|
229
|
-
return
|
|
326
|
+
return {"manifest": manifest, "children": children}
|
|
230
327
|
|
|
231
328
|
|
|
232
329
|
def _load_children(home: Path, manager_id: str, task_group: str, task_id: str) -> dict:
|
|
@@ -286,6 +383,7 @@ def assign_child(
|
|
|
286
383
|
assignment: str,
|
|
287
384
|
child_task_id: str | None = None,
|
|
288
385
|
) -> dict:
|
|
386
|
+
_read_json(task_manifest_path(home, manager_id, task_group, task_id))
|
|
289
387
|
payload = _load_children(home, manager_id, task_group, task_id)
|
|
290
388
|
child = _select_child_for_assignment(payload["children"], project_id, task_group, child_task_id)
|
|
291
389
|
child["role"] = role
|
|
@@ -311,6 +409,11 @@ def append_directive(
|
|
|
311
409
|
raise ManagerError("scope must be shared or project")
|
|
312
410
|
if scope == "project" and not project_id:
|
|
313
411
|
raise ManagerError("project directive requires project-id")
|
|
412
|
+
_read_json(task_manifest_path(home, manager_id, task_group, task_id))
|
|
413
|
+
if scope == "project":
|
|
414
|
+
children = _load_children(home, manager_id, task_group, task_id)["children"]
|
|
415
|
+
if not any(isinstance(child, dict) and child.get("projectId") == project_id for child in children):
|
|
416
|
+
raise ManagerError(f"project directive target has no child in this task: {project_id}")
|
|
314
417
|
row = {
|
|
315
418
|
"scope": scope,
|
|
316
419
|
"projectId": project_id if scope == "project" else "",
|