okstra 0.189.3 → 0.190.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 (178) hide show
  1. package/README.md +1 -1
  2. package/dist/cli-registry.mjs +9 -0
  3. package/dist/cli-registry.mjs.map +1 -1
  4. package/dist/commands/chat/chat.mjs +54 -4
  5. package/dist/commands/chat/chat.mjs.map +1 -1
  6. package/dist/commands/lifecycle/install.mjs +12 -5
  7. package/dist/commands/lifecycle/install.mjs.map +1 -1
  8. package/dist/commands/lifecycle/uninstall.mjs +2 -1
  9. package/dist/commands/lifecycle/uninstall.mjs.map +1 -1
  10. package/dist/lib/host-config.d.mts +5 -3
  11. package/dist/lib/host-config.mjs +89 -31
  12. package/dist/lib/host-config.mjs.map +1 -1
  13. package/docs/architecture/storage-model.md +4 -1
  14. package/docs/architecture.md +3 -3
  15. package/docs/cli.md +11 -9
  16. package/docs/for-ai/skills/okstra-brief-gen.md +1 -1
  17. package/docs/for-ai/skills/okstra-chat.md +3 -3
  18. package/docs/for-ai/skills/okstra-inspect.md +11 -1
  19. package/docs/for-ai/skills/okstra-rollup.md +1 -0
  20. package/docs/for-ai/skills/okstra-run.md +5 -4
  21. package/docs/for-ai/skills/okstra-user-response.md +1 -1
  22. package/docs/project-structure-overview.md +17 -5
  23. package/docs/task-process/README.md +1 -1
  24. package/docs/task-process/error-analysis.md +2 -2
  25. package/docs/task-process/final-verification.md +1 -1
  26. package/docs/task-process/implementation.md +1 -1
  27. package/docs/task-process/release-handoff.md +8 -6
  28. package/docs/task-process/requirements-discovery.md +1 -1
  29. package/package.json +1 -1
  30. package/runtime/BUILD.json +2 -2
  31. package/runtime/agents/workers/claude-worker.md +4 -0
  32. package/runtime/bin/okstra-report-translate.py +10 -4
  33. package/runtime/prompts/launch.template.md +11 -5
  34. package/runtime/prompts/lead/adapters/cmux.md +4 -7
  35. package/runtime/prompts/lead/context-loader.md +2 -0
  36. package/runtime/prompts/lead/convergence.md +20 -83
  37. package/runtime/prompts/lead/okstra-lead-contract.md +24 -6
  38. package/runtime/prompts/lead/plan-body-verification.md +10 -3
  39. package/runtime/prompts/lead/report-writer.md +15 -5
  40. package/runtime/prompts/lead/team-contract.md +15 -9
  41. package/runtime/prompts/profiles/_common-contract.md +2 -1
  42. package/runtime/prompts/profiles/_implementation-deliverable.md +2 -0
  43. package/runtime/prompts/profiles/_implementation-diff-review.md +4 -0
  44. package/runtime/prompts/profiles/_implementation-executor.md +9 -5
  45. package/runtime/prompts/profiles/_implementation-self-check.md +2 -0
  46. package/runtime/prompts/profiles/_implementation-verifier.md +3 -4
  47. package/runtime/prompts/profiles/_stage-discipline.md +12 -5
  48. package/runtime/prompts/profiles/final-verification.md +1 -1
  49. package/runtime/prompts/profiles/implementation-planning.md +1 -1
  50. package/runtime/prompts/profiles/implementation.md +2 -0
  51. package/runtime/prompts/profiles/release-handoff.md +4 -3
  52. package/runtime/prompts/wizard/prompts.ko.json +2 -1
  53. package/runtime/python/okstra_ctl/adapters/dispatch/cmux.py +6 -0
  54. package/runtime/python/okstra_ctl/adapters/hosts/antigravity/adapter.py +1 -0
  55. package/runtime/python/okstra_ctl/adapters/hosts/antigravity/relay.md +2 -2
  56. package/runtime/python/okstra_ctl/adapters/hosts/capability_adapter.py +9 -0
  57. package/runtime/python/okstra_ctl/adapters/hosts/claude-code/adapter.py +1 -0
  58. package/runtime/python/okstra_ctl/adapters/hosts/claude-code/relay.md +3 -3
  59. package/runtime/python/okstra_ctl/adapters/hosts/codex/adapter.py +1 -0
  60. package/runtime/python/okstra_ctl/adapters/hosts/codex/relay.md +17 -3
  61. package/runtime/python/okstra_ctl/adapters/hosts/external/adapter.py +1 -0
  62. package/runtime/python/okstra_ctl/adapters/hosts/external/relay.md +3 -3
  63. package/runtime/python/okstra_ctl/adapters/hosts/grok/adapter.py +1 -0
  64. package/runtime/python/okstra_ctl/adapters/hosts/grok/relay.md +2 -2
  65. package/runtime/python/okstra_ctl/adapters/hosts/kimi/adapter.py +1 -0
  66. package/runtime/python/okstra_ctl/adapters/hosts/kimi/relay.md +2 -2
  67. package/runtime/python/okstra_ctl/adapters/providers/codex/adapter.py +3 -3
  68. package/runtime/python/okstra_ctl/agent/invocation.py +107 -5
  69. package/runtime/python/okstra_ctl/agent/prompt_cli/cli.py +16 -0
  70. package/runtime/python/okstra_ctl/agent/prompt_cli/dynamic_verifier.py +3 -2
  71. package/runtime/python/okstra_ctl/agent/prompt_cli/jobs.py +87 -0
  72. package/runtime/python/okstra_ctl/agent/prompt_cli/materialize.py +72 -3
  73. package/runtime/python/okstra_ctl/analysis_packet.py +47 -6
  74. package/runtime/python/okstra_ctl/cmux.py +34 -13
  75. package/runtime/python/okstra_ctl/consumers.py +30 -7
  76. package/runtime/python/okstra_ctl/convergence.py +110 -21
  77. package/runtime/python/okstra_ctl/convergence_provenance.py +83 -0
  78. package/runtime/python/okstra_ctl/dispatch_core.py +127 -5
  79. package/runtime/python/okstra_ctl/dispatch_state.py +17 -9
  80. package/runtime/python/okstra_ctl/domain/host.py +5 -0
  81. package/runtime/python/okstra_ctl/domain/wizard/interaction.py +1 -0
  82. package/runtime/python/okstra_ctl/error_log_write.py +44 -4
  83. package/runtime/python/okstra_ctl/execution_mutation_audit.py +84 -7
  84. package/runtime/python/okstra_ctl/fixed_text.py +25 -7
  85. package/runtime/python/okstra_ctl/group_context.py +475 -10
  86. package/runtime/python/okstra_ctl/handoff.py +109 -32
  87. package/runtime/python/okstra_ctl/handoff_verification.py +80 -0
  88. package/runtime/python/okstra_ctl/initial_prompt_materialization.py +52 -10
  89. package/runtime/python/okstra_ctl/lead_progress.py +291 -0
  90. package/runtime/python/okstra_ctl/model_io/renderers.py +60 -1
  91. package/runtime/python/okstra_ctl/model_io_cli.py +6 -1
  92. package/runtime/python/okstra_ctl/ports/worker_dispatch.py +4 -0
  93. package/runtime/python/okstra_ctl/recap.py +179 -17
  94. package/runtime/python/okstra_ctl/reconcile.py +55 -4
  95. package/runtime/python/okstra_ctl/render.py +34 -8
  96. package/runtime/python/okstra_ctl/report_assembly.py +17 -10
  97. package/runtime/python/okstra_ctl/report_finalize.py +204 -11
  98. package/runtime/python/okstra_ctl/report_html/common.py +241 -113
  99. package/runtime/python/okstra_ctl/report_html/context_links.py +121 -0
  100. package/runtime/python/okstra_ctl/report_html/models.py +11 -5
  101. package/runtime/python/okstra_ctl/report_html/render.py +69 -17
  102. package/runtime/python/okstra_ctl/report_html/view_models/change_impact_analysis.py +8 -1
  103. package/runtime/python/okstra_ctl/report_html/view_models/error_analysis.py +8 -1
  104. package/runtime/python/okstra_ctl/report_html/view_models/feature_analysis.py +11 -1
  105. package/runtime/python/okstra_ctl/report_html/view_models/final_verification.py +13 -1
  106. package/runtime/python/okstra_ctl/report_html/view_models/implementation.py +9 -1
  107. package/runtime/python/okstra_ctl/report_html/view_models/implementation_option_selection.py +29 -2
  108. package/runtime/python/okstra_ctl/report_html/view_models/implementation_planning.py +14 -9
  109. package/runtime/python/okstra_ctl/report_html/view_models/improvement_discovery.py +8 -1
  110. package/runtime/python/okstra_ctl/report_html/view_models/project_analysis.py +15 -1
  111. package/runtime/python/okstra_ctl/report_html/view_models/release_handoff.py +7 -1
  112. package/runtime/python/okstra_ctl/report_html/view_models/requirements_discovery.py +9 -1
  113. package/runtime/python/okstra_ctl/report_translation.py +58 -1
  114. package/runtime/python/okstra_ctl/run.py +54 -14
  115. package/runtime/python/okstra_ctl/stage_targets.py +66 -0
  116. package/runtime/python/okstra_ctl/verdict_blocks.py +39 -12
  117. package/runtime/python/okstra_ctl/wizard/__init__.py +148 -0
  118. package/runtime/python/okstra_ctl/wizard/__main__.py +13 -0
  119. package/runtime/python/okstra_ctl/wizard/cli.py +176 -0
  120. package/runtime/python/okstra_ctl/wizard/confirmation.py +331 -0
  121. package/runtime/python/okstra_ctl/wizard/engine.py +363 -0
  122. package/runtime/python/okstra_ctl/wizard/ids.py +411 -0
  123. package/runtime/python/okstra_ctl/wizard/prompts.py +202 -0
  124. package/runtime/python/okstra_ctl/wizard/registry.py +836 -0
  125. package/runtime/python/okstra_ctl/wizard/render.py +189 -0
  126. package/runtime/python/okstra_ctl/wizard/roles.py +737 -0
  127. package/runtime/python/okstra_ctl/wizard/sources.py +703 -0
  128. package/runtime/python/okstra_ctl/wizard/state.py +689 -0
  129. package/runtime/python/okstra_ctl/wizard/statefile.py +145 -0
  130. package/runtime/python/okstra_ctl/wizard/steps_analysis.py +558 -0
  131. package/runtime/python/okstra_ctl/wizard/steps_identity.py +828 -0
  132. package/runtime/python/okstra_ctl/wizard/steps_options.py +340 -0
  133. package/runtime/python/okstra_ctl/wizard/steps_plan.py +959 -0
  134. package/runtime/python/okstra_ctl/wizard/steps_roles.py +672 -0
  135. package/runtime/python/okstra_ctl/worker_prompt_contract.py +43 -3
  136. package/runtime/python/okstra_ctl/worker_prompt_headers.py +14 -0
  137. package/runtime/python/okstra_ctl/workflow.py +2 -2
  138. package/runtime/python/okstra_ctl/worktree/__init__.py +2 -0
  139. package/runtime/python/okstra_ctl/worktree/cleanliness.py +7 -4
  140. package/runtime/python/okstra_ctl/worktree/git_ops.py +7 -0
  141. package/runtime/python/okstra_ctl/worktree/naming.py +15 -6
  142. package/runtime/python/okstra_ctl/worktree/provision.py +2 -1
  143. package/runtime/python/okstra_ctl/worktree_registry.py +27 -0
  144. package/runtime/python/okstra_ctl/wrapper_status.py +4 -0
  145. package/runtime/schemas/final-report-v2.0.schema.json +4 -0
  146. package/runtime/schemas/final-report-v3.0.schema.json +4 -0
  147. package/runtime/skills/okstra-brief-gen/SKILL.md +22 -3
  148. package/runtime/skills/okstra-chat/SKILL.md +8 -6
  149. package/runtime/skills/okstra-inspect/SKILL.md +2 -2
  150. package/runtime/skills/okstra-inspect/facets/recap.md +52 -4
  151. package/runtime/skills/okstra-rollup/SKILL.md +1 -1
  152. package/runtime/skills/okstra-run/SKILL.md +17 -7
  153. package/runtime/skills/okstra-schedule-gen/SKILL.md +2 -0
  154. package/runtime/skills/okstra-user-response/SKILL.md +1 -1
  155. package/runtime/templates/reports/brief.template.md +15 -7
  156. package/runtime/templates/reports/final-verification-input.template.md +2 -0
  157. package/runtime/templates/reports/group-context.template.md +7 -0
  158. package/runtime/templates/reports/html/base.template.html +13 -3
  159. package/runtime/templates/reports/html/i18n/en.json +43 -5
  160. package/runtime/templates/reports/html/i18n/ko.json +43 -5
  161. package/runtime/templates/reports/html/macros/forms.html +4 -4
  162. package/runtime/templates/reports/html/tasks/final-verification.template.html +11 -1
  163. package/runtime/templates/reports/html/tasks/implementation-option-selection.template.html +16 -15
  164. package/runtime/templates/reports/html/tasks/implementation-planning.template.html +5 -5
  165. package/runtime/templates/reports/html/tasks/release-handoff.template.html +2 -2
  166. package/runtime/templates/reports/html/tasks/requirements-discovery.template.html +1 -1
  167. package/runtime/templates/reports/implementation-planning-input.template.md +3 -0
  168. package/runtime/templates/reports/improvement-discovery-input.template.md +3 -0
  169. package/runtime/templates/reports/release-handoff-input.template.md +5 -3
  170. package/runtime/templates/reports/schedule.template.md +13 -4
  171. package/runtime/templates/reports/task-brief.template.md +1 -1
  172. package/runtime/templates/reverify-output-contract.md +5 -0
  173. package/runtime/templates/worker-error-contract.md +2 -0
  174. package/runtime/templates/worker-prompt-preamble.md +1 -1
  175. package/runtime/validators/validate-report-views.py +30 -1
  176. package/runtime/validators/validate-run.py +194 -25
  177. package/runtime/validators/validate_session_conformance.py +102 -2
  178. package/runtime/python/okstra_ctl/wizard.py +0 -7168
@@ -6,13 +6,25 @@
6
6
  `cache` 그룹: 브리프 15개 중 가용성 사고를 말한 것이 0개였고 option-selection 이
7
7
  분모를 페이지당 요청 수로 바꿔 판단했다).
8
8
 
9
- 세 소비자가 이 모듈 하나를 쓴다.
9
+ 파일은 두 소유자가 나눠 쓴다. 위의 네 절은 사용자가 쓰고, 파일 끝의 마커 영역
10
+ (`MEMORY_BEGIN`…`MEMORY_END`, `## Task Memory`)은 okstra 가 report-finalize 마다
11
+ 다시 쓴다 — 그룹의 각 task 가 마지막 run 에서 낸 결론(headline·decisions·
12
+ follow-up·레코드 경로)이다. 형제 task 의 run 은 이 영역을 패킷의
13
+ `## Task-Group Memory` 로 받는다(2026-09-07: cache 그룹 15개 티켓 중 첫 티켓의
14
+ run 8개 결론이 나머지 14개에 닿는 통로가 없었다).
10
15
 
11
- - `okstra group-context init` — 템플릿에서 뼈대를 쓴다(있으면 거절).
16
+ 네 소비자가 이 모듈 하나를 쓴다.
17
+
18
+ - `okstra group-context init` — 템플릿에서 뼈대를 쓴다. okstra 가 메모리만 써 둔
19
+ 파일이면 그 위에 사람 절 뼈대를 끼워 넣고, 사람 절이 있는 파일은 덮지 않는다.
12
20
  - `validators/validate-brief.py` — 프론트매터 `type: group-context` 를 만나면
13
- `validate_group_context` 로 검사한다.
21
+ `validate_group_context` 로 검사한다. 사람 절이 하나도 없는 메모리 전용 파일은
22
+ 네 절 규칙을 적용하지 않는다.
14
23
  - prepare(`run.py`) — 파일이 있으면 같은 검사를 통과해야 진행하고, 통과하면
15
24
  `instruction-set/task-group-context.md` 로 복사해 analysis packet 에 싣는다.
25
+ 패킷은 `split_memory_region` 으로 사람 절과 메모리를 갈라 따로 배치한다.
26
+ - report-finalize `record-group-memory` — `record_task_memory` 로 이 run 의
27
+ 항목을 영역에 쓴다. 파일이 없으면 메모리 전용 파일을 만든다.
16
28
  """
17
29
  from __future__ import annotations
18
30
 
@@ -20,15 +32,24 @@ import argparse
20
32
  import datetime as dt
21
33
  import re
22
34
  import sys
35
+ from dataclasses import dataclass, replace
23
36
  from pathlib import Path
37
+ from typing import Any, Mapping
38
+
39
+ from okstra_project import list_project_tasks
40
+ from okstra_project.phase_pointer import STATUS_TERMINAL
24
41
 
25
42
  from .brief_frontmatter import read_brief_frontmatter
26
43
  from .ids import slugify_task_segment
27
44
  from .paths import find_asset_root
45
+ from .run_context import dir_flock
28
46
 
29
47
  GROUP_CONTEXT_FILENAME = "group-context.md"
30
48
  GROUP_CONTEXT_TYPE = "group-context"
31
49
  GROUP_CONTEXT_GENERATOR = "okstra-brief-gen"
50
+ # 파일이 없을 때 finalize 가 메모리 전용 파일을 만들며 적는 생성자.
51
+ MEMORY_GENERATOR = "okstra-report-finalize"
52
+ GENERATORS = (GROUP_CONTEXT_GENERATOR, MEMORY_GENERATOR)
32
53
  INSTRUCTION_SET_FILENAME = "task-group-context.md"
33
54
  REQUIRED_FRONTMATTER_KEYS = ("type", "task-group", "created", "generator")
34
55
  SECTIONS = (
@@ -44,6 +65,431 @@ TEMPLATE_RELATIVE = ("templates", "reports", "group-context.template.md")
44
65
  _HTML_COMMENT_RE = re.compile(r"<!--.*?-->", re.DOTALL)
45
66
  _NONE_MARKERS = {"_(none)_", ""}
46
67
 
68
+ # --- okstra 소유 영역 ----------------------------------------------------------
69
+
70
+ MEMORY_BEGIN = "<!-- okstra:task-memory:begin -->"
71
+ MEMORY_END = "<!-- okstra:task-memory:end -->"
72
+ MEMORY_NOTE = (
73
+ "<!-- okstra rewrites this region at every report-finalize. "
74
+ "Edit the sections above it, not this one. -->"
75
+ )
76
+ MEMORY_HEADING = "## Task Memory"
77
+ MEMORY_EMPTY = "_(no runs recorded yet)_"
78
+ # 실측(2026-09-07 cache 그룹 dev-10626): decisions 한 줄이 250~400자, 캡 없이 한 task 가
79
+ # 3.5KB 였다. 15개 task 그룹이 패킷에 실리므로 task 당 1.5KB 안팎으로 잡는다.
80
+ HEADLINE_MAX = 240
81
+ ITEM_MAX = 160
82
+ DECISIONS_MAX = 3
83
+ FOLLOW_UPS_MAX = 3
84
+ WATCH_OUT_MAX = 2
85
+ LOCK_FILENAME = ".group-context.lock"
86
+ _FOLLOW_UP_SEP = "; "
87
+ _LATEST_RE = re.compile(r"^- latest: (?P<type>\S+) #(?P<seq>\S+) · (?P<date>\S+) · next: (?P<next>.*)$")
88
+
89
+
90
+ @dataclass(frozen=True)
91
+ class MemoryEntry:
92
+ """한 task 의 마지막 run 결론. `decisions` 만 이전 항목과 합쳐 누적한다."""
93
+
94
+ task_id: str
95
+ task_type: str
96
+ seq: str
97
+ date: str
98
+ next_phase: str
99
+ headline: str
100
+ decisions: tuple[str, ...]
101
+ follow_ups: tuple[str, ...]
102
+ record: str
103
+ watch_out: tuple[str, ...] = ()
104
+
105
+
106
+ def memory_entry_from_record(
107
+ data: Mapping[str, Any],
108
+ *,
109
+ task_id: str,
110
+ task_type: str,
111
+ seq: str,
112
+ record: str,
113
+ next_phase: str,
114
+ today: dt.date,
115
+ ) -> MemoryEntry:
116
+ """final-report 레코드에서 항목을 뽑는다. 없는 필드는 빈 값이지 실패가 아니다."""
117
+ summary = data.get("humanSummary")
118
+ summary = summary if isinstance(summary, Mapping) else {}
119
+ header = data.get("header")
120
+ header = header if isinstance(header, Mapping) else {}
121
+ created = header.get("createdAt")
122
+ date = created[:10] if isinstance(created, str) and len(created) >= 10 else today.isoformat()
123
+ follow_ups = tuple(
124
+ _clip(_one_line(row["title"]), ITEM_MAX)
125
+ for row in (data.get("followUpTasks") or [])
126
+ if isinstance(row, Mapping)
127
+ and row.get("origin") != "phase-continuation"
128
+ and isinstance(row.get("title"), str)
129
+ and row["title"].strip()
130
+ )
131
+ return MemoryEntry(
132
+ task_id=slugify_task_segment(task_id),
133
+ task_type=task_type,
134
+ seq=seq,
135
+ date=date,
136
+ next_phase=next_phase,
137
+ headline=_clip(_one_line(summary.get("headline")), HEADLINE_MAX),
138
+ decisions=tuple(
139
+ _clip(_one_line(item), ITEM_MAX) for item in (summary.get("decisions") or [])
140
+ if isinstance(item, str) and item.strip()
141
+ ),
142
+ follow_ups=follow_ups,
143
+ record=record,
144
+ watch_out=tuple(
145
+ _clip(_one_line(item), ITEM_MAX) for item in (summary.get("blockers") or [])
146
+ if isinstance(item, str) and item.strip()
147
+ ),
148
+ )
149
+
150
+
151
+ def _one_line(value: Any) -> str:
152
+ return " ".join(str(value).split()) if isinstance(value, str) else ""
153
+
154
+
155
+ def _clip(text: str, limit: int) -> str:
156
+ return text if len(text) <= limit else text[: limit - 1].rstrip() + "…"
157
+
158
+
159
+ def split_memory_region(text: str) -> tuple[str, str, str]:
160
+ """(마커 앞, 마커 사이, 마커 뒤). 영역이 없으면 사이와 뒤는 빈 문자열.
161
+
162
+ begin 만 있으면 파일 끝까지를 영역으로 본다. end 만 있으면 영역이 없는 것으로
163
+ 본다 — 어느 쪽이든 다음 쓰기가 균형 잡힌 영역으로 다시 쓴다.
164
+ """
165
+ begin = text.find(MEMORY_BEGIN)
166
+ if begin < 0:
167
+ return text, "", ""
168
+ inner_start = begin + len(MEMORY_BEGIN)
169
+ end = text.find(MEMORY_END, inner_start)
170
+ if end < 0:
171
+ return text[:begin], text[inner_start:], ""
172
+ return text[:begin], text[inner_start:end], text[end + len(MEMORY_END):]
173
+
174
+
175
+ def parse_memory_entries(region: str) -> list[MemoryEntry]:
176
+ """영역 본문 → 항목. `### <task-id>` 가 항목을 연다. 모르는 줄은 버린다."""
177
+ entries: list[MemoryEntry] = []
178
+ current: dict[str, Any] | None = None
179
+ in_decisions = in_watch_out = False
180
+ for raw in region.splitlines():
181
+ line = raw.rstrip()
182
+ if line.startswith("### "):
183
+ if current is not None:
184
+ entries.append(_entry_from_fields(current))
185
+ current = {"task_id": line[4:].strip(), "decisions": [], "watch_out": []}
186
+ in_decisions = in_watch_out = False
187
+ continue
188
+ if current is None:
189
+ continue
190
+ if line.startswith(" - ") and (in_decisions or in_watch_out):
191
+ current["decisions" if in_decisions else "watch_out"].append(line[4:].strip())
192
+ continue
193
+ in_decisions = in_watch_out = False
194
+ match = _LATEST_RE.match(line)
195
+ if match:
196
+ current.update(
197
+ task_type=match.group("type"), seq=match.group("seq"),
198
+ date=match.group("date"), next_phase=match.group("next").strip(),
199
+ )
200
+ elif line.startswith("- headline: "):
201
+ current["headline"] = line[len("- headline: "):].strip()
202
+ elif line == "- decisions:":
203
+ in_decisions = True
204
+ elif line.startswith("- open follow-ups: "):
205
+ value = line[len("- open follow-ups: "):].strip()
206
+ current["follow_ups"] = [] if value in _NONE_MARKERS else value.split(_FOLLOW_UP_SEP)
207
+ elif line == "- watch out:":
208
+ in_watch_out = True
209
+ elif line.startswith("- record: "):
210
+ current["record"] = line[len("- record: "):].strip().strip("`")
211
+ if current is not None:
212
+ entries.append(_entry_from_fields(current))
213
+ return entries
214
+
215
+
216
+ def _entry_from_fields(fields: Mapping[str, Any]) -> MemoryEntry:
217
+ return MemoryEntry(
218
+ task_id=fields.get("task_id", ""),
219
+ task_type=fields.get("task_type", ""),
220
+ seq=fields.get("seq", ""),
221
+ date=fields.get("date", ""),
222
+ next_phase=fields.get("next_phase", ""),
223
+ headline=fields.get("headline", ""),
224
+ decisions=tuple(fields.get("decisions", [])),
225
+ follow_ups=tuple(fields.get("follow_ups", [])),
226
+ record=fields.get("record", ""),
227
+ watch_out=tuple(fields.get("watch_out", [])),
228
+ )
229
+
230
+
231
+ def render_memory_entries(entries: list[MemoryEntry]) -> str:
232
+ """항목들 → `### ` 블록. 빈 목록은 `MEMORY_EMPTY` 한 줄."""
233
+ if not entries:
234
+ return MEMORY_EMPTY + "\n"
235
+ blocks: list[str] = []
236
+ for entry in entries:
237
+ lines = [
238
+ f"### {entry.task_id}",
239
+ f"- latest: {entry.task_type} #{entry.seq} · {entry.date} · next: {entry.next_phase or '_(none)_'}",
240
+ f"- headline: {entry.headline or '_(none)_'}",
241
+ ]
242
+ if entry.decisions:
243
+ lines.append("- decisions:")
244
+ lines.extend(f" - {item}" for item in entry.decisions)
245
+ if entry.watch_out:
246
+ lines.append("- watch out:")
247
+ lines.extend(f" - {item}" for item in entry.watch_out)
248
+ lines.append(
249
+ "- open follow-ups: "
250
+ + (_FOLLOW_UP_SEP.join(entry.follow_ups) if entry.follow_ups else "_(none)_")
251
+ )
252
+ lines.append(f"- record: `{entry.record}`" if entry.record else "- record: _(none)_")
253
+ blocks.append("\n".join(lines) + "\n")
254
+ return "\n".join(blocks)
255
+
256
+
257
+ def render_memory_region(entries: list[MemoryEntry], queue: list[QueueRow] | None = None) -> str:
258
+ parts = [MEMORY_BEGIN, MEMORY_NOTE, MEMORY_HEADING, ""]
259
+ queue_text = render_queue(queue or [])
260
+ if queue_text:
261
+ parts.extend([queue_text.rstrip("\n"), ""])
262
+ parts.extend([render_memory_entries(entries).rstrip("\n"), MEMORY_END])
263
+ return "\n".join(parts) + "\n"
264
+
265
+
266
+ def merge_memory_entry(entries: list[MemoryEntry], entry: MemoryEntry) -> list[MemoryEntry]:
267
+ """같은 task 의 항목을 갈아 끼우고 decisions 는 새 것 우선으로 합쳐 상한까지 남긴다."""
268
+ previous = next((e for e in entries if e.task_id == entry.task_id), None)
269
+ decisions: list[str] = []
270
+ for item in (*entry.decisions, *(previous.decisions if previous else ())):
271
+ if item and item not in decisions:
272
+ decisions.append(item)
273
+ merged = replace(
274
+ entry,
275
+ decisions=tuple(decisions[:DECISIONS_MAX]),
276
+ follow_ups=entry.follow_ups[:FOLLOW_UPS_MAX],
277
+ watch_out=entry.watch_out[:WATCH_OUT_MAX],
278
+ )
279
+ rest = [e for e in entries if e.task_id != entry.task_id]
280
+ return sorted([*rest, merged], key=lambda e: (e.date, e.task_id), reverse=True)
281
+
282
+
283
+ def memory_only_document(task_group: str, created: str) -> str:
284
+ """사람 절 없이 프론트매터·제목·빈 영역만 있는 파일. finalize 가 파일이 없을 때 만든다."""
285
+ slug = slugify_task_segment(task_group)
286
+ return (
287
+ "---\n"
288
+ f"type: {GROUP_CONTEXT_TYPE}\n"
289
+ f"task-group: {slug}\n"
290
+ f"created: {created}\n"
291
+ f"generator: {MEMORY_GENERATOR}\n"
292
+ "---\n"
293
+ "\n"
294
+ f"# Task-Group Context: {slug}\n"
295
+ "\n"
296
+ + render_memory_region([])
297
+ )
298
+
299
+
300
+ def record_task_memory(
301
+ project_root: Path, task_group: str, entry: MemoryEntry, *, today: dt.date
302
+ ) -> tuple[Path, list[QueueRow]]:
303
+ """이 run 의 항목을 그룹 문서의 okstra 영역에 쓰고 (경로, 시작 순서 큐)를 돌려준다.
304
+
305
+ 파일이 없으면 만든다. 사람 절은 바이트 그대로 둔다. 같은 그룹의 finalize 가
306
+ 동시에 돌 수 있으므로 디렉터리 flock 아래에서 읽고-바꾸고-쓴다.
307
+ """
308
+ target = group_context_file(project_root, task_group)
309
+ target.parent.mkdir(parents=True, exist_ok=True)
310
+ with dir_flock(target.parent, LOCK_FILENAME):
311
+ text = (
312
+ target.read_text(encoding="utf-8") if target.is_file()
313
+ else memory_only_document(task_group, today.isoformat())
314
+ )
315
+ before, region, after = split_memory_region(text)
316
+ entries = merge_memory_entry(parse_memory_entries(region), entry)
317
+ queue = group_queue(project_root, task_group, entries)
318
+ if not before.endswith("\n"):
319
+ before += "\n"
320
+ if before.strip() and not before.endswith("\n\n"):
321
+ before += "\n"
322
+ target.write_text(before + render_memory_region(entries, queue) + after, encoding="utf-8")
323
+ return target, queue
324
+
325
+
326
+ # --- 시작 순서 ----------------------------------------------------------------
327
+ #
328
+ # 그룹의 task 는 순서가 있을 수 있다. 순서는 brief-gen 이 매긴 브리프 순번
329
+ # (`DEV-10627-2-…` 의 `2`)이다 — 사용자가 분할 때 정한 순서다. 브리프의 Related
330
+ # Task Graph 에 적힌 `depends-on` / `blocks` / `blocked-by` 간선은 재정렬하지 않고
331
+ # `waits for` 로 붙여 보이기만 한다: 실측(2026-09-07 cache 그룹) 에서 그래프는
332
+ # 15번 가드 티켓이 1번을 `blocks` 한다고 적었지만 사용자는 1번을 먼저 끝냈다.
333
+ # 큐는 영역 머리에 실리고, 이 task 가 끝나는 closeout 은 큐의 다음 task 를 이름한다.
334
+
335
+ QUEUE_DONE = "done"
336
+ QUEUE_IN_PROGRESS = "in progress"
337
+ QUEUE_NOT_STARTED = "not started"
338
+ _ORDINAL_RE = re.compile(r"^(?P<ticket>[A-Za-z]+-\d+)-(?P<ordinal>\d+)-")
339
+ _WAIT_RELATIONS = {"depends-on", "blocked-by"}
340
+ _BLOCK_RELATIONS = {"blocks"}
341
+ _QUEUE_INTRO = (
342
+ "Start order (brief ordinal; `waits for` quotes `depends-on` / `blocks` edges "
343
+ "from the briefs' Related Task Graph whose named ticket is not done):"
344
+ )
345
+ _QUEUE_LINE_RE = re.compile(r"^\d+\. \[(?P<status>[^\]]*)\] (?P<task>\S+)")
346
+
347
+
348
+ @dataclass(frozen=True)
349
+ class QueueRow:
350
+ task_id: str # slugified brief-id — the task directory name
351
+ brief_id: str
352
+ ticket_id: str
353
+ brief: str # project-relative brief path
354
+ status: str # QUEUE_*
355
+ progress: str # "<task-type> #<seq>" for a recorded task, else ""
356
+ waits_for: tuple[str, ...] = () # ticket ids the graph says come first and are not done
357
+
358
+
359
+ def group_briefs(project_root: Path, task_group: str) -> list[dict[str, Any]]:
360
+ """그룹 디렉터리의 브리프(프론트매터 `type: brief`)와 각 브리프의 대기 간선."""
361
+ group_dir = group_context_file(project_root, task_group).parent
362
+ if not group_dir.is_dir():
363
+ return []
364
+ briefs: list[dict[str, Any]] = []
365
+ for path in sorted(group_dir.rglob("*.md")):
366
+ if is_group_context_file(path) or not path.is_file():
367
+ continue
368
+ frontmatter = read_brief_frontmatter(path)
369
+ if frontmatter.get("type") != "brief":
370
+ continue
371
+ brief_id = frontmatter.get("brief-id") or path.stem
372
+ match = _ORDINAL_RE.match(brief_id)
373
+ briefs.append({
374
+ "brief_id": brief_id,
375
+ "ticket_id": frontmatter.get("ticket-id") or (match.group("ticket") if match else brief_id),
376
+ "ordinal": int(match.group("ordinal")) if match else None,
377
+ "brief": path.relative_to(project_root).as_posix(),
378
+ "waits_for": _wait_edges(path.read_text(encoding="utf-8")),
379
+ })
380
+ return briefs
381
+
382
+
383
+ def _wait_edges(text: str) -> dict[str, set[str]]:
384
+ """Related Task Graph 표 → {ticket: 먼저 끝나야 하는 ticket 들}."""
385
+ waits: dict[str, set[str]] = {}
386
+ for line in _section_bodies(text).get("Related Task Graph", "").splitlines():
387
+ cells = [cell.strip() for cell in line.strip().strip("|").split("|")]
388
+ if len(cells) < 3:
389
+ continue
390
+ source, relation, target = cells[0], cells[1], cells[2]
391
+ if relation in _WAIT_RELATIONS:
392
+ waits.setdefault(source, set()).add(target)
393
+ elif relation in _BLOCK_RELATIONS:
394
+ waits.setdefault(target, set()).add(source)
395
+ return waits
396
+
397
+
398
+ def catalog_progress(project_root: Path, task_group: str) -> dict[str, tuple[str, str]]:
399
+ """카탈로그에 있는 그룹 task → (큐 상태, 진행 표기). task-manifest 가 정본이다.
400
+
401
+ 메모리 항목은 2026-09-07 이후의 finalize 만 쓰므로 그 전에 끝난 task 는 항목이
402
+ 없다(실측: cache 그룹 dev-10626 — run 13개, release-handoff 완료인데 큐는
403
+ `[not started]`). 카탈로그 항목은 `list_project_tasks` 가 task-manifest 의
404
+ phase·상태·다음 phase 포인터로 덮어 돌려주므로 여기서 다시 읽지 않는다.
405
+ """
406
+ wanted = slugify_task_segment(task_group)
407
+ out: dict[str, tuple[str, str]] = {}
408
+ for entry in list_project_tasks(project_root):
409
+ if slugify_task_segment(str(entry.get("taskGroup") or "")) != wanted:
410
+ continue
411
+ task_id = slugify_task_segment(str(entry.get("taskId") or ""))
412
+ if not task_id:
413
+ continue
414
+ pointer = entry.get("nextRecommendedPhase")
415
+ pointer = pointer if isinstance(pointer, Mapping) else {}
416
+ phase = str(entry.get("currentPhase") or entry.get("taskType") or "")
417
+ state = str(entry.get("currentPhaseState") or entry.get("latestRunStatus") or "")
418
+ progress = f"{phase} ({state})" if phase and state else phase
419
+ status = QUEUE_DONE if pointer.get("status") == STATUS_TERMINAL else QUEUE_IN_PROGRESS
420
+ out[task_id] = (status, progress)
421
+ return out
422
+
423
+
424
+ def group_queue(
425
+ project_root: Path, task_group: str, entries: list[MemoryEntry]
426
+ ) -> list[QueueRow]:
427
+ """브리프를 순번 순서로 늘어놓고 카탈로그, 그 다음 기록된 항목으로 상태를 매긴다.
428
+
429
+ 순번이 없는 브리프는 뒤에 id 순이다. 그래프 간선은 순서를 바꾸지 않고 아직
430
+ 끝나지 않은 선행 ticket 을 `waits_for` 로 붙인다. 카탈로그에 있는 task 는
431
+ task-manifest 가 상태를 정하고, 메모리 항목은 카탈로그가 모르는 task 에만
432
+ 쓰인다(`catalog_progress`).
433
+ """
434
+ briefs = group_briefs(project_root, task_group)
435
+ if not briefs:
436
+ return []
437
+ by_ticket = {brief["ticket_id"]: brief for brief in briefs}
438
+ waits: dict[str, set[str]] = {}
439
+ for brief in briefs:
440
+ for ticket, before in brief["waits_for"].items():
441
+ waits.setdefault(ticket, set()).update((before & by_ticket.keys()) - {ticket})
442
+ ordered = sorted(briefs, key=lambda b: (b["ordinal"] is None, b["ordinal"] or 0, b["brief_id"]))
443
+ by_task = {entry.task_id: entry for entry in entries}
444
+ recorded = catalog_progress(project_root, task_group)
445
+
446
+ def _status(brief: dict[str, Any]) -> tuple[str, str]:
447
+ task_id = slugify_task_segment(brief["brief_id"])
448
+ if task_id in recorded:
449
+ return recorded[task_id]
450
+ entry = by_task.get(task_id)
451
+ if entry is None:
452
+ return QUEUE_NOT_STARTED, ""
453
+ done = entry.next_phase.endswith("(terminal)")
454
+ return (QUEUE_DONE if done else QUEUE_IN_PROGRESS), f"{entry.task_type} #{entry.seq}"
455
+
456
+ status_by_ticket = {brief["ticket_id"]: _status(brief) for brief in ordered}
457
+ rows: list[QueueRow] = []
458
+ for brief in ordered:
459
+ status, progress = status_by_ticket[brief["ticket_id"]]
460
+ open_waits = tuple(sorted(
461
+ ticket for ticket in waits.get(brief["ticket_id"], set())
462
+ if status_by_ticket[ticket][0] != QUEUE_DONE
463
+ ))
464
+ rows.append(QueueRow(
465
+ slugify_task_segment(brief["brief_id"]), brief["brief_id"], brief["ticket_id"],
466
+ brief["brief"], status, progress, open_waits,
467
+ ))
468
+ return rows
469
+
470
+
471
+ def next_in_group(queue: list[QueueRow]) -> QueueRow | None:
472
+ """시작 순서에서 아직 시작하지 않은 첫 task."""
473
+ return next((row for row in queue if row.status == QUEUE_NOT_STARTED), None)
474
+
475
+
476
+ def render_queue(queue: list[QueueRow]) -> str:
477
+ if not queue:
478
+ return ""
479
+ lines = [_QUEUE_INTRO, ""]
480
+ for number, row in enumerate(queue, start=1):
481
+ tail = f" — {row.progress}" if row.progress else ""
482
+ if row.waits_for:
483
+ tail += f" (waits for {', '.join(row.waits_for)})"
484
+ lines.append(f"{number}. [{row.status}] {row.task_id}{tail}")
485
+ return "\n".join(lines) + "\n"
486
+
487
+
488
+ def has_human_sections(text: str) -> bool:
489
+ """마커 밖에 `## ` 절이 하나라도 있는가. 없으면 메모리 전용 문서다."""
490
+ before, _, after = split_memory_region(text)
491
+ return any(line.startswith("## ") for line in (before + after).splitlines())
492
+
47
493
 
48
494
  def briefs_root(project_root: Path) -> Path:
49
495
  return Path(project_root) / ".okstra" / "briefs"
@@ -60,7 +506,7 @@ def is_group_context_file(path: Path) -> bool:
60
506
 
61
507
  def validate_group_context(path: Path, root: Path) -> list[str]:
62
508
  """결함 목록. 비어 있으면 통과. 결함마다 사람이 고칠 자리를 이름한다."""
63
- text = _HTML_COMMENT_RE.sub("", path.read_text(encoding="utf-8"))
509
+ raw = path.read_text(encoding="utf-8")
64
510
  errors: list[str] = []
65
511
  frontmatter = read_brief_frontmatter(path)
66
512
  if not frontmatter:
@@ -72,13 +518,17 @@ def validate_group_context(path: Path, root: Path) -> list[str]:
72
518
  errors.append(
73
519
  f"frontmatter type must be {GROUP_CONTEXT_TYPE!r}, got {frontmatter.get('type')!r}"
74
520
  )
75
- if frontmatter.get("generator") != GROUP_CONTEXT_GENERATOR:
521
+ if frontmatter.get("generator") not in GENERATORS:
76
522
  errors.append(
77
- f"frontmatter generator must be {GROUP_CONTEXT_GENERATOR!r}, "
523
+ f"frontmatter generator must be one of {list(GENERATORS)}, "
78
524
  f"got {frontmatter.get('generator')!r}"
79
525
  )
80
526
  errors.extend(_path_errors(path, root, frontmatter.get("task-group", "")))
81
- errors.extend(_section_errors(text))
527
+ # okstra 가 쓴 영역은 검사하지 않는다. 사람 절이 하나라도 있으면 네 절 규칙.
528
+ # 마커가 HTML 주석이라 영역을 가른 뒤에 주석을 지운다.
529
+ if has_human_sections(raw):
530
+ human_text, _, human_tail = split_memory_region(raw)
531
+ errors.extend(_section_errors(_HTML_COMMENT_RE.sub("", human_text + human_tail)))
82
532
  return errors
83
533
 
84
534
 
@@ -161,12 +611,23 @@ def render_skeleton(task_group: str, created: str) -> str:
161
611
 
162
612
 
163
613
  def init_group_context(project_root: Path, task_group: str, *, today: dt.date) -> Path:
164
- """뼈대를 쓴다. 이미 있으면 `FileExistsError` — 채운 문서를 덮어쓰지 않는다."""
614
+ """뼈대를 쓴다. 사람 절이 있는 파일은 `FileExistsError` — 채운 문서를 덮어쓰지 않는다.
615
+
616
+ okstra 가 메모리만 써 둔 파일이면 그 영역을 그대로 두고 사람 절 뼈대를 앞에 끼운다.
617
+ """
165
618
  target = group_context_file(project_root, task_group)
619
+ skeleton = render_skeleton(task_group, today.isoformat())
166
620
  if target.exists():
167
- raise FileExistsError(str(target))
621
+ existing = target.read_text(encoding="utf-8")
622
+ if has_human_sections(existing):
623
+ raise FileExistsError(str(target))
624
+ _, region, after = split_memory_region(existing)
625
+ human, _, _ = split_memory_region(skeleton)
626
+ entries = parse_memory_entries(region)
627
+ target.write_text(human + render_memory_region(entries) + after, encoding="utf-8")
628
+ return target
168
629
  target.parent.mkdir(parents=True, exist_ok=True)
169
- target.write_text(render_skeleton(task_group, today.isoformat()), encoding="utf-8")
630
+ target.write_text(skeleton, encoding="utf-8")
170
631
  return target
171
632
 
172
633
 
@@ -195,6 +656,10 @@ def _init_command(args: argparse.Namespace) -> int:
195
656
  f"`{slugify_task_segment(args.task_group)}` refuses to prepare while a placeholder "
196
657
  "line remains; delete the file if the group needs no context."
197
658
  )
659
+ print(
660
+ f"The trailing `{MEMORY_HEADING}` region is written by okstra at every "
661
+ "report-finalize; leave it alone."
662
+ )
198
663
  return 0
199
664
 
200
665