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.
- package/README.md +1 -1
- package/dist/cli-registry.mjs +9 -0
- package/dist/cli-registry.mjs.map +1 -1
- package/dist/commands/chat/chat.mjs +54 -4
- package/dist/commands/chat/chat.mjs.map +1 -1
- package/dist/commands/lifecycle/install.mjs +12 -5
- package/dist/commands/lifecycle/install.mjs.map +1 -1
- package/dist/commands/lifecycle/uninstall.mjs +2 -1
- package/dist/commands/lifecycle/uninstall.mjs.map +1 -1
- package/dist/lib/host-config.d.mts +5 -3
- package/dist/lib/host-config.mjs +89 -31
- package/dist/lib/host-config.mjs.map +1 -1
- package/docs/architecture/storage-model.md +4 -1
- package/docs/architecture.md +3 -3
- package/docs/cli.md +11 -9
- package/docs/for-ai/skills/okstra-brief-gen.md +1 -1
- package/docs/for-ai/skills/okstra-chat.md +3 -3
- package/docs/for-ai/skills/okstra-inspect.md +11 -1
- package/docs/for-ai/skills/okstra-rollup.md +1 -0
- package/docs/for-ai/skills/okstra-run.md +5 -4
- package/docs/for-ai/skills/okstra-user-response.md +1 -1
- package/docs/project-structure-overview.md +17 -5
- package/docs/task-process/README.md +1 -1
- package/docs/task-process/error-analysis.md +2 -2
- package/docs/task-process/final-verification.md +1 -1
- package/docs/task-process/implementation.md +1 -1
- package/docs/task-process/release-handoff.md +8 -6
- package/docs/task-process/requirements-discovery.md +1 -1
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/agents/workers/claude-worker.md +4 -0
- package/runtime/bin/okstra-report-translate.py +10 -4
- package/runtime/prompts/launch.template.md +11 -5
- package/runtime/prompts/lead/adapters/cmux.md +4 -7
- package/runtime/prompts/lead/context-loader.md +2 -0
- package/runtime/prompts/lead/convergence.md +20 -83
- package/runtime/prompts/lead/okstra-lead-contract.md +24 -6
- package/runtime/prompts/lead/plan-body-verification.md +10 -3
- package/runtime/prompts/lead/report-writer.md +15 -5
- package/runtime/prompts/lead/team-contract.md +15 -9
- package/runtime/prompts/profiles/_common-contract.md +2 -1
- package/runtime/prompts/profiles/_implementation-deliverable.md +2 -0
- package/runtime/prompts/profiles/_implementation-diff-review.md +4 -0
- package/runtime/prompts/profiles/_implementation-executor.md +9 -5
- package/runtime/prompts/profiles/_implementation-self-check.md +2 -0
- package/runtime/prompts/profiles/_implementation-verifier.md +3 -4
- package/runtime/prompts/profiles/_stage-discipline.md +12 -5
- package/runtime/prompts/profiles/final-verification.md +1 -1
- package/runtime/prompts/profiles/implementation-planning.md +1 -1
- package/runtime/prompts/profiles/implementation.md +2 -0
- package/runtime/prompts/profiles/release-handoff.md +4 -3
- package/runtime/prompts/wizard/prompts.ko.json +2 -1
- package/runtime/python/okstra_ctl/adapters/dispatch/cmux.py +6 -0
- package/runtime/python/okstra_ctl/adapters/hosts/antigravity/adapter.py +1 -0
- package/runtime/python/okstra_ctl/adapters/hosts/antigravity/relay.md +2 -2
- package/runtime/python/okstra_ctl/adapters/hosts/capability_adapter.py +9 -0
- package/runtime/python/okstra_ctl/adapters/hosts/claude-code/adapter.py +1 -0
- package/runtime/python/okstra_ctl/adapters/hosts/claude-code/relay.md +3 -3
- package/runtime/python/okstra_ctl/adapters/hosts/codex/adapter.py +1 -0
- package/runtime/python/okstra_ctl/adapters/hosts/codex/relay.md +17 -3
- package/runtime/python/okstra_ctl/adapters/hosts/external/adapter.py +1 -0
- package/runtime/python/okstra_ctl/adapters/hosts/external/relay.md +3 -3
- package/runtime/python/okstra_ctl/adapters/hosts/grok/adapter.py +1 -0
- package/runtime/python/okstra_ctl/adapters/hosts/grok/relay.md +2 -2
- package/runtime/python/okstra_ctl/adapters/hosts/kimi/adapter.py +1 -0
- package/runtime/python/okstra_ctl/adapters/hosts/kimi/relay.md +2 -2
- package/runtime/python/okstra_ctl/adapters/providers/codex/adapter.py +3 -3
- package/runtime/python/okstra_ctl/agent/invocation.py +107 -5
- package/runtime/python/okstra_ctl/agent/prompt_cli/cli.py +16 -0
- package/runtime/python/okstra_ctl/agent/prompt_cli/dynamic_verifier.py +3 -2
- package/runtime/python/okstra_ctl/agent/prompt_cli/jobs.py +87 -0
- package/runtime/python/okstra_ctl/agent/prompt_cli/materialize.py +72 -3
- package/runtime/python/okstra_ctl/analysis_packet.py +47 -6
- package/runtime/python/okstra_ctl/cmux.py +34 -13
- package/runtime/python/okstra_ctl/consumers.py +30 -7
- package/runtime/python/okstra_ctl/convergence.py +110 -21
- package/runtime/python/okstra_ctl/convergence_provenance.py +83 -0
- package/runtime/python/okstra_ctl/dispatch_core.py +127 -5
- package/runtime/python/okstra_ctl/dispatch_state.py +17 -9
- package/runtime/python/okstra_ctl/domain/host.py +5 -0
- package/runtime/python/okstra_ctl/domain/wizard/interaction.py +1 -0
- package/runtime/python/okstra_ctl/error_log_write.py +44 -4
- package/runtime/python/okstra_ctl/execution_mutation_audit.py +84 -7
- package/runtime/python/okstra_ctl/fixed_text.py +25 -7
- package/runtime/python/okstra_ctl/group_context.py +475 -10
- package/runtime/python/okstra_ctl/handoff.py +109 -32
- package/runtime/python/okstra_ctl/handoff_verification.py +80 -0
- package/runtime/python/okstra_ctl/initial_prompt_materialization.py +52 -10
- package/runtime/python/okstra_ctl/lead_progress.py +291 -0
- package/runtime/python/okstra_ctl/model_io/renderers.py +60 -1
- package/runtime/python/okstra_ctl/model_io_cli.py +6 -1
- package/runtime/python/okstra_ctl/ports/worker_dispatch.py +4 -0
- package/runtime/python/okstra_ctl/recap.py +179 -17
- package/runtime/python/okstra_ctl/reconcile.py +55 -4
- package/runtime/python/okstra_ctl/render.py +34 -8
- package/runtime/python/okstra_ctl/report_assembly.py +17 -10
- package/runtime/python/okstra_ctl/report_finalize.py +204 -11
- package/runtime/python/okstra_ctl/report_html/common.py +241 -113
- package/runtime/python/okstra_ctl/report_html/context_links.py +121 -0
- package/runtime/python/okstra_ctl/report_html/models.py +11 -5
- package/runtime/python/okstra_ctl/report_html/render.py +69 -17
- package/runtime/python/okstra_ctl/report_html/view_models/change_impact_analysis.py +8 -1
- package/runtime/python/okstra_ctl/report_html/view_models/error_analysis.py +8 -1
- package/runtime/python/okstra_ctl/report_html/view_models/feature_analysis.py +11 -1
- package/runtime/python/okstra_ctl/report_html/view_models/final_verification.py +13 -1
- package/runtime/python/okstra_ctl/report_html/view_models/implementation.py +9 -1
- package/runtime/python/okstra_ctl/report_html/view_models/implementation_option_selection.py +29 -2
- package/runtime/python/okstra_ctl/report_html/view_models/implementation_planning.py +14 -9
- package/runtime/python/okstra_ctl/report_html/view_models/improvement_discovery.py +8 -1
- package/runtime/python/okstra_ctl/report_html/view_models/project_analysis.py +15 -1
- package/runtime/python/okstra_ctl/report_html/view_models/release_handoff.py +7 -1
- package/runtime/python/okstra_ctl/report_html/view_models/requirements_discovery.py +9 -1
- package/runtime/python/okstra_ctl/report_translation.py +58 -1
- package/runtime/python/okstra_ctl/run.py +54 -14
- package/runtime/python/okstra_ctl/stage_targets.py +66 -0
- package/runtime/python/okstra_ctl/verdict_blocks.py +39 -12
- package/runtime/python/okstra_ctl/wizard/__init__.py +148 -0
- package/runtime/python/okstra_ctl/wizard/__main__.py +13 -0
- package/runtime/python/okstra_ctl/wizard/cli.py +176 -0
- package/runtime/python/okstra_ctl/wizard/confirmation.py +331 -0
- package/runtime/python/okstra_ctl/wizard/engine.py +363 -0
- package/runtime/python/okstra_ctl/wizard/ids.py +411 -0
- package/runtime/python/okstra_ctl/wizard/prompts.py +202 -0
- package/runtime/python/okstra_ctl/wizard/registry.py +836 -0
- package/runtime/python/okstra_ctl/wizard/render.py +189 -0
- package/runtime/python/okstra_ctl/wizard/roles.py +737 -0
- package/runtime/python/okstra_ctl/wizard/sources.py +703 -0
- package/runtime/python/okstra_ctl/wizard/state.py +689 -0
- package/runtime/python/okstra_ctl/wizard/statefile.py +145 -0
- package/runtime/python/okstra_ctl/wizard/steps_analysis.py +558 -0
- package/runtime/python/okstra_ctl/wizard/steps_identity.py +828 -0
- package/runtime/python/okstra_ctl/wizard/steps_options.py +340 -0
- package/runtime/python/okstra_ctl/wizard/steps_plan.py +959 -0
- package/runtime/python/okstra_ctl/wizard/steps_roles.py +672 -0
- package/runtime/python/okstra_ctl/worker_prompt_contract.py +43 -3
- package/runtime/python/okstra_ctl/worker_prompt_headers.py +14 -0
- package/runtime/python/okstra_ctl/workflow.py +2 -2
- package/runtime/python/okstra_ctl/worktree/__init__.py +2 -0
- package/runtime/python/okstra_ctl/worktree/cleanliness.py +7 -4
- package/runtime/python/okstra_ctl/worktree/git_ops.py +7 -0
- package/runtime/python/okstra_ctl/worktree/naming.py +15 -6
- package/runtime/python/okstra_ctl/worktree/provision.py +2 -1
- package/runtime/python/okstra_ctl/worktree_registry.py +27 -0
- package/runtime/python/okstra_ctl/wrapper_status.py +4 -0
- package/runtime/schemas/final-report-v2.0.schema.json +4 -0
- package/runtime/schemas/final-report-v3.0.schema.json +4 -0
- package/runtime/skills/okstra-brief-gen/SKILL.md +22 -3
- package/runtime/skills/okstra-chat/SKILL.md +8 -6
- package/runtime/skills/okstra-inspect/SKILL.md +2 -2
- package/runtime/skills/okstra-inspect/facets/recap.md +52 -4
- package/runtime/skills/okstra-rollup/SKILL.md +1 -1
- package/runtime/skills/okstra-run/SKILL.md +17 -7
- package/runtime/skills/okstra-schedule-gen/SKILL.md +2 -0
- package/runtime/skills/okstra-user-response/SKILL.md +1 -1
- package/runtime/templates/reports/brief.template.md +15 -7
- package/runtime/templates/reports/final-verification-input.template.md +2 -0
- package/runtime/templates/reports/group-context.template.md +7 -0
- package/runtime/templates/reports/html/base.template.html +13 -3
- package/runtime/templates/reports/html/i18n/en.json +43 -5
- package/runtime/templates/reports/html/i18n/ko.json +43 -5
- package/runtime/templates/reports/html/macros/forms.html +4 -4
- package/runtime/templates/reports/html/tasks/final-verification.template.html +11 -1
- package/runtime/templates/reports/html/tasks/implementation-option-selection.template.html +16 -15
- package/runtime/templates/reports/html/tasks/implementation-planning.template.html +5 -5
- package/runtime/templates/reports/html/tasks/release-handoff.template.html +2 -2
- package/runtime/templates/reports/html/tasks/requirements-discovery.template.html +1 -1
- package/runtime/templates/reports/implementation-planning-input.template.md +3 -0
- package/runtime/templates/reports/improvement-discovery-input.template.md +3 -0
- package/runtime/templates/reports/release-handoff-input.template.md +5 -3
- package/runtime/templates/reports/schedule.template.md +13 -4
- package/runtime/templates/reports/task-brief.template.md +1 -1
- package/runtime/templates/reverify-output-contract.md +5 -0
- package/runtime/templates/worker-error-contract.md +2 -0
- package/runtime/templates/worker-prompt-preamble.md +1 -1
- package/runtime/validators/validate-report-views.py +30 -1
- package/runtime/validators/validate-run.py +194 -25
- package/runtime/validators/validate_session_conformance.py +102 -2
- 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
|
-
|
|
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
|
-
|
|
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")
|
|
521
|
+
if frontmatter.get("generator") not in GENERATORS:
|
|
76
522
|
errors.append(
|
|
77
|
-
f"frontmatter generator must be {
|
|
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
|
-
|
|
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
|
-
"""뼈대를 쓴다.
|
|
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
|
-
|
|
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(
|
|
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
|
|