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.
Files changed (41) hide show
  1. package/dist/commands/lifecycle/setup.mjs +15 -0
  2. package/dist/commands/lifecycle/setup.mjs.map +1 -1
  3. package/dist/lib/citation-guidance.d.mts +21 -0
  4. package/dist/lib/citation-guidance.mjs +79 -0
  5. package/dist/lib/citation-guidance.mjs.map +1 -0
  6. package/docs/architecture/storage-model.md +4 -0
  7. package/docs/architecture.md +1 -1
  8. package/docs/cli.md +2 -2
  9. package/docs/for-ai/skills/okstra-manager.md +21 -4
  10. package/docs/for-ai/skills/okstra-setup.md +9 -0
  11. package/docs/project-structure-overview.md +4 -1
  12. package/package.json +1 -1
  13. package/runtime/BUILD.json +2 -2
  14. package/runtime/prompts/launch.template.md +1 -1
  15. package/runtime/prompts/lead/okstra-lead-contract.md +2 -2
  16. package/runtime/prompts/profiles/_implementation-verifier.md +1 -1
  17. package/runtime/prompts/profiles/implementation-planning.md +2 -0
  18. package/runtime/python/okstra_ctl/convergence_critic_prompt.py +4 -6
  19. package/runtime/python/okstra_ctl/convergence_provenance.py +75 -18
  20. package/runtime/python/okstra_ctl/execution_mutation_audit.py +21 -21
  21. package/runtime/python/okstra_ctl/manager_cli.py +85 -12
  22. package/runtime/python/okstra_ctl/manager_launch.py +40 -18
  23. package/runtime/python/okstra_ctl/manager_paths.py +8 -0
  24. package/runtime/python/okstra_ctl/manager_split.py +474 -0
  25. package/runtime/python/okstra_ctl/manager_store.py +121 -18
  26. package/runtime/python/okstra_ctl/manager_sync.py +33 -15
  27. package/runtime/python/okstra_ctl/manager_view.py +216 -0
  28. package/runtime/python/okstra_ctl/plan_items_cli.py +6 -1
  29. package/runtime/python/okstra_ctl/qa_commands.py +15 -0
  30. package/runtime/python/okstra_ctl/report_finalize.py +13 -6
  31. package/runtime/python/okstra_ctl/report_html/view_models/final_verification.py +2 -21
  32. package/runtime/python/okstra_ctl/report_html/visualizations.py +0 -5
  33. package/runtime/python/okstra_ctl/verification_target.py +13 -2
  34. package/runtime/skills/okstra-manager/SKILL.md +53 -4
  35. package/runtime/skills/okstra-run/SKILL.md +1 -1
  36. package/runtime/skills/okstra-setup/SKILL.md +9 -0
  37. package/runtime/templates/manager/view.template.html +108 -0
  38. package/runtime/templates/reports/html/i18n/en.json +2 -0
  39. package/runtime/templates/reports/html/i18n/ko.json +2 -0
  40. package/runtime/templates/reports/html/tasks/final-verification.template.html +2 -2
  41. 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 = "manual",
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
- manifest = {
192
- "managerId": manager_id,
193
- "taskGroup": task_group,
194
- "taskId": task_id,
195
- "progressMode": progress_mode,
196
- "commonBriefPath": common_brief_path,
197
- "objective": objective,
198
- "createdAt": when,
199
- }
200
- children = []
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
- children.append(
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": f"{project_id}:{task_group}:{child_task_id}",
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
- payload = {"manifest": manifest, "children": {"children": children}}
223
- _write_json(task_manifest_path(home, manager_id, task_group, task_id), manifest)
224
- _write_json(children_json_path(home, manager_id, task_group, task_id), payload["children"])
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
- {"event": "task-created", "createdAt": when},
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 payload
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 "",