okstra 0.189.0 → 0.189.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli-registry.mjs +6 -0
- package/dist/cli-registry.mjs.map +1 -1
- package/docs/architecture/storage-model.md +9 -0
- package/docs/architecture.md +2 -0
- package/docs/cli.md +4 -1
- package/docs/for-ai/skills/okstra-brief-gen.md +2 -0
- package/docs/project-structure-overview.md +3 -1
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/prompts/duties/direction-selection-worker.md +2 -2
- package/runtime/prompts/lead/report-writer.md +1 -1
- package/runtime/prompts/profiles/_clarification-recommendation.md +1 -1
- package/runtime/prompts/profiles/_common-contract.md +1 -0
- package/runtime/prompts/profiles/implementation-option-selection.md +2 -2
- package/runtime/python/okstra_ctl/analysis_packet.py +38 -0
- package/runtime/python/okstra_ctl/approval_decisions.py +85 -0
- package/runtime/python/okstra_ctl/group_context.py +223 -0
- package/runtime/python/okstra_ctl/implementation_options.py +35 -0
- package/runtime/python/okstra_ctl/model_io/renderers.py +12 -7
- package/runtime/python/okstra_ctl/recap.py +7 -1
- package/runtime/python/okstra_ctl/render.py +13 -0
- package/runtime/python/okstra_ctl/report_html/common.py +60 -4
- package/runtime/python/okstra_ctl/report_html/context_links.py +137 -0
- package/runtime/python/okstra_ctl/report_html/filters.py +22 -20
- package/runtime/python/okstra_ctl/report_html/render.py +18 -5
- package/runtime/python/okstra_ctl/report_html/view_models/implementation_option_selection.py +7 -0
- package/runtime/python/okstra_ctl/report_synthesis_packet.py +5 -0
- package/runtime/python/okstra_ctl/report_translation.py +8 -0
- package/runtime/python/okstra_ctl/run.py +28 -0
- package/runtime/python/okstra_ctl/scope_provenance.py +28 -6
- package/runtime/python/okstra_ctl/timeline_runs.py +71 -0
- package/runtime/python/okstra_ctl/wizard.py +2 -1
- package/runtime/skills/okstra-brief-gen/SKILL.md +29 -1
- package/runtime/skills/okstra-inspect/facets/history.md +1 -0
- package/runtime/skills/okstra-inspect/facets/recap.md +1 -1
- package/runtime/templates/reports/group-context.template.md +31 -0
- package/runtime/templates/reports/html/assets/base.css +10 -0
- package/runtime/templates/reports/html/base.template.html +10 -0
- package/runtime/templates/reports/html/i18n/en.json +71 -2
- package/runtime/templates/reports/html/i18n/ko.json +71 -2
- package/runtime/templates/reports/html/macros/forms.html +1 -0
- package/runtime/templates/reports/html/tasks/implementation-option-selection.template.html +55 -11
- package/runtime/validators/validate-brief.py +9 -0
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
"""task-group 맥락 문서 — 그룹의 모든 task 가 공유하는 배경을 워커 입력에 싣는다.
|
|
2
|
+
|
|
3
|
+
`.okstra/briefs/<task-group>/group-context.md` 한 파일이 그룹이 존재하는 이유,
|
|
4
|
+
성과 척도, 그룹 전체 금지선, 티켓 간 관계를 담는다. 브리프는 티켓 하나의 범위를
|
|
5
|
+
말하므로 그룹 단위의 "왜" 는 어느 브리프에도 없다(2026-09-04 실측, fontsninja
|
|
6
|
+
`cache` 그룹: 브리프 15개 중 가용성 사고를 말한 것이 0개였고 option-selection 이
|
|
7
|
+
분모를 페이지당 요청 수로 바꿔 판단했다).
|
|
8
|
+
|
|
9
|
+
세 소비자가 이 모듈 하나를 쓴다.
|
|
10
|
+
|
|
11
|
+
- `okstra group-context init` — 템플릿에서 뼈대를 쓴다(있으면 거절).
|
|
12
|
+
- `validators/validate-brief.py` — 프론트매터 `type: group-context` 를 만나면
|
|
13
|
+
`validate_group_context` 로 검사한다.
|
|
14
|
+
- prepare(`run.py`) — 파일이 있으면 같은 검사를 통과해야 진행하고, 통과하면
|
|
15
|
+
`instruction-set/task-group-context.md` 로 복사해 analysis packet 에 싣는다.
|
|
16
|
+
"""
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import argparse
|
|
20
|
+
import datetime as dt
|
|
21
|
+
import re
|
|
22
|
+
import sys
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
|
|
25
|
+
from .brief_frontmatter import read_brief_frontmatter
|
|
26
|
+
from .ids import slugify_task_segment
|
|
27
|
+
from .paths import find_asset_root
|
|
28
|
+
|
|
29
|
+
GROUP_CONTEXT_FILENAME = "group-context.md"
|
|
30
|
+
GROUP_CONTEXT_TYPE = "group-context"
|
|
31
|
+
GROUP_CONTEXT_GENERATOR = "okstra-brief-gen"
|
|
32
|
+
INSTRUCTION_SET_FILENAME = "task-group-context.md"
|
|
33
|
+
REQUIRED_FRONTMATTER_KEYS = ("type", "task-group", "created", "generator")
|
|
34
|
+
SECTIONS = (
|
|
35
|
+
"Why This Group Exists",
|
|
36
|
+
"Definition of Better",
|
|
37
|
+
"Group-Wide Constraints",
|
|
38
|
+
"Ticket Relations",
|
|
39
|
+
)
|
|
40
|
+
# 나머지 두 절은 `_(none)_` 이 허용된다 — 금지선이나 관계가 없는 그룹도 있다.
|
|
41
|
+
MUST_BE_FILLED = SECTIONS[:2]
|
|
42
|
+
TEMPLATE_RELATIVE = ("templates", "reports", "group-context.template.md")
|
|
43
|
+
|
|
44
|
+
_HTML_COMMENT_RE = re.compile(r"<!--.*?-->", re.DOTALL)
|
|
45
|
+
_NONE_MARKERS = {"_(none)_", ""}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def briefs_root(project_root: Path) -> Path:
|
|
49
|
+
return Path(project_root) / ".okstra" / "briefs"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def group_context_file(project_root: Path, task_group: str) -> Path:
|
|
53
|
+
"""그룹 맥락 문서의 정본 경로. 디렉터리는 브리프와 같은 slug 를 쓴다."""
|
|
54
|
+
return briefs_root(project_root) / slugify_task_segment(task_group) / GROUP_CONTEXT_FILENAME
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def is_group_context_file(path: Path) -> bool:
|
|
58
|
+
return path.name == GROUP_CONTEXT_FILENAME
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def validate_group_context(path: Path, root: Path) -> list[str]:
|
|
62
|
+
"""결함 목록. 비어 있으면 통과. 결함마다 사람이 고칠 자리를 이름한다."""
|
|
63
|
+
text = _HTML_COMMENT_RE.sub("", path.read_text(encoding="utf-8"))
|
|
64
|
+
errors: list[str] = []
|
|
65
|
+
frontmatter = read_brief_frontmatter(path)
|
|
66
|
+
if not frontmatter:
|
|
67
|
+
return ["frontmatter: missing or malformed (a `---` block must open line 1)"]
|
|
68
|
+
missing = [key for key in REQUIRED_FRONTMATTER_KEYS if key not in frontmatter]
|
|
69
|
+
if missing:
|
|
70
|
+
errors.append(f"frontmatter missing keys: {missing}")
|
|
71
|
+
if frontmatter.get("type") != GROUP_CONTEXT_TYPE:
|
|
72
|
+
errors.append(
|
|
73
|
+
f"frontmatter type must be {GROUP_CONTEXT_TYPE!r}, got {frontmatter.get('type')!r}"
|
|
74
|
+
)
|
|
75
|
+
if frontmatter.get("generator") != GROUP_CONTEXT_GENERATOR:
|
|
76
|
+
errors.append(
|
|
77
|
+
f"frontmatter generator must be {GROUP_CONTEXT_GENERATOR!r}, "
|
|
78
|
+
f"got {frontmatter.get('generator')!r}"
|
|
79
|
+
)
|
|
80
|
+
errors.extend(_path_errors(path, root, frontmatter.get("task-group", "")))
|
|
81
|
+
errors.extend(_section_errors(text))
|
|
82
|
+
return errors
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _path_errors(path: Path, root: Path, task_group: str) -> list[str]:
|
|
86
|
+
errors: list[str] = []
|
|
87
|
+
if path.name != GROUP_CONTEXT_FILENAME:
|
|
88
|
+
errors.append(f"file must be named {GROUP_CONTEXT_FILENAME!r}, got {path.name!r}")
|
|
89
|
+
try:
|
|
90
|
+
relative = path.resolve().relative_to(Path(root).resolve())
|
|
91
|
+
except ValueError:
|
|
92
|
+
return errors + [f"file is not under the briefs root {root}"]
|
|
93
|
+
if len(relative.parts) != 2:
|
|
94
|
+
errors.append(
|
|
95
|
+
"file must sit directly under its task-group directory "
|
|
96
|
+
f"(`<briefs>/<task-group>/{GROUP_CONTEXT_FILENAME}`), got {relative}"
|
|
97
|
+
)
|
|
98
|
+
return errors
|
|
99
|
+
expected = slugify_task_segment(task_group)
|
|
100
|
+
if task_group and relative.parts[0] != expected:
|
|
101
|
+
errors.append(
|
|
102
|
+
f"task-group directory segment {relative.parts[0]!r} does not match the "
|
|
103
|
+
f"slugified frontmatter task-group {expected!r}"
|
|
104
|
+
)
|
|
105
|
+
return errors
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _section_errors(text: str) -> list[str]:
|
|
109
|
+
bodies = _section_bodies(text)
|
|
110
|
+
errors: list[str] = []
|
|
111
|
+
for heading in SECTIONS:
|
|
112
|
+
if heading not in bodies:
|
|
113
|
+
errors.append(f"missing section `## {heading}`")
|
|
114
|
+
continue
|
|
115
|
+
lines = [line.strip() for line in bodies[heading].splitlines() if line.strip()]
|
|
116
|
+
placeholders = [line for line in lines if _is_template_placeholder(line)]
|
|
117
|
+
if placeholders:
|
|
118
|
+
errors.append(
|
|
119
|
+
f"section `## {heading}` still carries a template placeholder "
|
|
120
|
+
f"({placeholders[0][:40]}...); fill it or delete the file"
|
|
121
|
+
)
|
|
122
|
+
continue
|
|
123
|
+
if heading in MUST_BE_FILLED and all(line in _NONE_MARKERS for line in lines):
|
|
124
|
+
errors.append(f"section `## {heading}` must be filled; `_(none)_` is not accepted here")
|
|
125
|
+
return errors
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _section_bodies(text: str) -> dict[str, str]:
|
|
129
|
+
"""`## ` 제목 → 본문. `## ` 만 절을 닫는다(validate-brief `section_body` 와 같은 규칙)."""
|
|
130
|
+
bodies: dict[str, str] = {}
|
|
131
|
+
current: str | None = None
|
|
132
|
+
buffer: list[str] = []
|
|
133
|
+
for line in text.splitlines():
|
|
134
|
+
if line.startswith("## "):
|
|
135
|
+
if current is not None:
|
|
136
|
+
bodies[current] = "\n".join(buffer)
|
|
137
|
+
current = line[3:].strip()
|
|
138
|
+
buffer = []
|
|
139
|
+
elif current is not None:
|
|
140
|
+
buffer.append(line)
|
|
141
|
+
if current is not None:
|
|
142
|
+
bodies[current] = "\n".join(buffer)
|
|
143
|
+
return bodies
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _is_template_placeholder(line: str) -> bool:
|
|
147
|
+
bare = line.lstrip("-").strip()
|
|
148
|
+
return bare.startswith("<") and bare.endswith(">")
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def render_skeleton(task_group: str, created: str) -> str:
|
|
152
|
+
root = find_asset_root(TEMPLATE_RELATIVE)
|
|
153
|
+
if root is None:
|
|
154
|
+
raise FileNotFoundError(
|
|
155
|
+
"group-context template not found in any okstra asset root: "
|
|
156
|
+
+ "/".join(TEMPLATE_RELATIVE)
|
|
157
|
+
)
|
|
158
|
+
template = root.joinpath(*TEMPLATE_RELATIVE).read_text(encoding="utf-8")
|
|
159
|
+
slug = slugify_task_segment(task_group)
|
|
160
|
+
return template.replace("<task-group>", slug).replace("<YYYY-MM-DD>", created)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def init_group_context(project_root: Path, task_group: str, *, today: dt.date) -> Path:
|
|
164
|
+
"""뼈대를 쓴다. 이미 있으면 `FileExistsError` — 채운 문서를 덮어쓰지 않는다."""
|
|
165
|
+
target = group_context_file(project_root, task_group)
|
|
166
|
+
if target.exists():
|
|
167
|
+
raise FileExistsError(str(target))
|
|
168
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
169
|
+
target.write_text(render_skeleton(task_group, today.isoformat()), encoding="utf-8")
|
|
170
|
+
return target
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _init_command(args: argparse.Namespace) -> int:
|
|
174
|
+
project_root = Path(args.project_root).resolve()
|
|
175
|
+
try:
|
|
176
|
+
target = init_group_context(project_root, args.task_group, today=dt.date.today())
|
|
177
|
+
except FileExistsError as exc:
|
|
178
|
+
print(
|
|
179
|
+
f"group-context: already exists: {exc}\n"
|
|
180
|
+
"Edit that file instead; delete it first if you want a fresh skeleton.",
|
|
181
|
+
file=sys.stderr,
|
|
182
|
+
)
|
|
183
|
+
return 2
|
|
184
|
+
print(f"group context skeleton: {target}")
|
|
185
|
+
print("Fill these sections (one `<...>` placeholder line each):")
|
|
186
|
+
for heading in SECTIONS:
|
|
187
|
+
note = "required" if heading in MUST_BE_FILLED else "`_(none)_` allowed"
|
|
188
|
+
print(f" - ## {heading} ({note})")
|
|
189
|
+
print(
|
|
190
|
+
"Validate with: python3 ~/.okstra/lib/validators/validate-brief.py "
|
|
191
|
+
f"{target} --briefs-root {briefs_root(project_root)}"
|
|
192
|
+
)
|
|
193
|
+
print(
|
|
194
|
+
"Every run of task-group "
|
|
195
|
+
f"`{slugify_task_segment(args.task_group)}` refuses to prepare while a placeholder "
|
|
196
|
+
"line remains; delete the file if the group needs no context."
|
|
197
|
+
)
|
|
198
|
+
return 0
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
202
|
+
parser = argparse.ArgumentParser(
|
|
203
|
+
prog="okstra group-context",
|
|
204
|
+
description=(
|
|
205
|
+
"Create the task-group context skeleton beside the group's briefs "
|
|
206
|
+
"(`.okstra/briefs/<task-group>/group-context.md`)."
|
|
207
|
+
),
|
|
208
|
+
)
|
|
209
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
210
|
+
init = sub.add_parser("init", help="write the skeleton from the template; refuses an existing file")
|
|
211
|
+
init.add_argument("--project-root", required=True, help="project root that holds `.okstra/`")
|
|
212
|
+
init.add_argument("--task-group", required=True, help="task-group name; slugified for the directory")
|
|
213
|
+
init.set_defaults(func=_init_command)
|
|
214
|
+
return parser
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def main(argv: list[str] | None = None) -> int:
|
|
218
|
+
args = build_parser().parse_args(argv)
|
|
219
|
+
return args.func(args)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
if __name__ == "__main__":
|
|
223
|
+
sys.exit(main())
|
|
@@ -244,14 +244,49 @@ def _validate_option_feasibility(
|
|
|
244
244
|
errors.append(f"{option_id} safetyBlockers must be empty")
|
|
245
245
|
if require_valid and option.get("unresolvedFeasibilityFacts"):
|
|
246
246
|
errors.append(f"{option_id} unresolvedFeasibilityFacts must be empty")
|
|
247
|
+
copied = _copied_votes(votes)
|
|
248
|
+
for first, second in copied:
|
|
249
|
+
errors.append(
|
|
250
|
+
f"{option_id} feasibilityVotes for {first} and {second} are identical; "
|
|
251
|
+
"each vote carries that analyser's own rationale and counterevidence"
|
|
252
|
+
)
|
|
247
253
|
return (
|
|
248
254
|
all_participated
|
|
249
255
|
and feasible >= MIN_FEASIBLE_VOTES
|
|
250
256
|
and not option.get("safetyBlockers")
|
|
251
257
|
and not option.get("unresolvedFeasibilityFacts")
|
|
258
|
+
and not copied
|
|
252
259
|
)
|
|
253
260
|
|
|
254
261
|
|
|
262
|
+
def _copied_votes(votes: Sequence[object]) -> list[tuple[str, str]]:
|
|
263
|
+
"""Pairs of analysers whose votes share one rationale and counterevidence.
|
|
264
|
+
|
|
265
|
+
A vote is that analyser's own finding. The writer synthesizes the row from
|
|
266
|
+
each result, and a run (2026-09-04, dev-10626) shipped three votes whose
|
|
267
|
+
sentences matched to the letter — one worker's text copied under the other
|
|
268
|
+
two names — so the votes said nothing a single vote did not. `uncertain`
|
|
269
|
+
votes are exempt: two analysers that never evaluated a candidate say so in
|
|
270
|
+
the same words legitimately.
|
|
271
|
+
"""
|
|
272
|
+
seen: dict[tuple[str, str], str] = {}
|
|
273
|
+
copied: list[tuple[str, str]] = []
|
|
274
|
+
for vote in votes:
|
|
275
|
+
if not isinstance(vote, Mapping) or vote.get("verdict") == "uncertain":
|
|
276
|
+
continue
|
|
277
|
+
key = (
|
|
278
|
+
str(vote.get("rationale") or "").strip(),
|
|
279
|
+
str(vote.get("counterevidence") or "").strip(),
|
|
280
|
+
)
|
|
281
|
+
if not any(key):
|
|
282
|
+
continue
|
|
283
|
+
worker = str(vote.get("worker") or "?")
|
|
284
|
+
first = seen.setdefault(key, worker)
|
|
285
|
+
if first != worker:
|
|
286
|
+
copied.append((first, worker))
|
|
287
|
+
return copied
|
|
288
|
+
|
|
289
|
+
|
|
255
290
|
def _validate_candidate(
|
|
256
291
|
option: Mapping[str, object],
|
|
257
292
|
original_ids: Sequence[str],
|
|
@@ -20,6 +20,7 @@ from ..fixed_text import line as _line, scalar as _value
|
|
|
20
20
|
from ..json_boundary import JsonBoundaryError, load_owned_object
|
|
21
21
|
from ..paths import okstra_home
|
|
22
22
|
from ..recap import assemble_recap
|
|
23
|
+
from ..timeline_runs import current_run_facts
|
|
23
24
|
from ..worker_artifacts import worker_provider_id
|
|
24
25
|
from .lines import (
|
|
25
26
|
_assignment_line,
|
|
@@ -51,7 +52,7 @@ from .references import (
|
|
|
51
52
|
)
|
|
52
53
|
|
|
53
54
|
|
|
54
|
-
def _latest_timeline_run(manifest_path: Path) -> Mapping[str, Any]:
|
|
55
|
+
def _latest_timeline_run(project_root: Path, manifest_path: Path) -> Mapping[str, Any]:
|
|
55
56
|
timeline_path = manifest_path.parent / "history" / "timeline.json"
|
|
56
57
|
if not timeline_path.is_file():
|
|
57
58
|
return {}
|
|
@@ -59,10 +60,11 @@ def _latest_timeline_run(manifest_path: Path) -> Mapping[str, Any]:
|
|
|
59
60
|
runs = timeline.get("runs")
|
|
60
61
|
if not isinstance(runs, list):
|
|
61
62
|
return {}
|
|
62
|
-
|
|
63
|
+
latest = next(
|
|
63
64
|
(run for run in reversed(runs) if isinstance(run, Mapping)),
|
|
64
|
-
|
|
65
|
+
None,
|
|
65
66
|
)
|
|
67
|
+
return current_run_facts(project_root, latest) if latest is not None else {}
|
|
66
68
|
|
|
67
69
|
|
|
68
70
|
def _overview_rows(
|
|
@@ -79,15 +81,18 @@ def _overview_rows(
|
|
|
79
81
|
project_root, manifest_path, expected_task_key=task_key, artifact="task catalog"
|
|
80
82
|
)
|
|
81
83
|
manifest = load_owned_object(manifest_path, artifact="task manifest")
|
|
82
|
-
latest_run = _latest_timeline_run(manifest_path)
|
|
84
|
+
latest_run = _latest_timeline_run(project_root, manifest_path)
|
|
83
85
|
_, group, _ = parse_task_key(task_key)
|
|
84
86
|
workflow = _mapping(manifest, "workflow")
|
|
85
87
|
next_phase = _mapping(workflow, "nextRecommendedPhase")
|
|
88
|
+
# task 단위 현재 사실은 task-manifest 가 권위다(validate-run 이 run 종료
|
|
89
|
+
# 시 거기에 쓴다). timeline 의 마지막 run 은 task-manifest 에 그 필드가
|
|
90
|
+
# 없을 때의 폴백이고, 그 값도 run-manifest 로 덮어쓴 현재 사실이다.
|
|
86
91
|
row = {
|
|
87
92
|
"taskKey": task_key,
|
|
88
93
|
"taskGroup": manifest.get("taskGroup") or task.get("taskGroup") or group,
|
|
89
94
|
"taskType": manifest.get("taskType") or latest_run.get("taskType"),
|
|
90
|
-
"latestRunStatus":
|
|
95
|
+
"latestRunStatus": manifest.get("latestRunStatus") or latest_run.get("status"),
|
|
91
96
|
"updatedAt": manifest.get("updatedAt") or task.get("updatedAt") or latest_run.get("runTimestamp"),
|
|
92
97
|
"latestRunManifestPath": pointer.get("latestRunManifestPath"),
|
|
93
98
|
"workCategory": manifest.get("workCategory"),
|
|
@@ -98,7 +103,7 @@ def _overview_rows(
|
|
|
98
103
|
"nextPhaseStatus": next_phase.get("status"),
|
|
99
104
|
"nextPhaseRationale": next_phase.get("rationale"),
|
|
100
105
|
"awaitingApproval": workflow.get("awaitingApproval"),
|
|
101
|
-
"latestReportRecordPath":
|
|
106
|
+
"latestReportRecordPath": manifest.get("latestReportRecordPath") or latest_run.get("reportRecordPath"),
|
|
102
107
|
"latestResumeCommandPath": latest_run.get("resumeCommandPath") or manifest.get("latestResumeCommandPath"),
|
|
103
108
|
"workStatus": manifest.get("workStatus"),
|
|
104
109
|
"lastRun": latest_run.get("runTimestamp"),
|
|
@@ -336,7 +341,7 @@ def render_history_input(
|
|
|
336
341
|
)
|
|
337
342
|
manifest, runs = _selected_task_data(project_root, task_ref)
|
|
338
343
|
blocks: list[str] = []
|
|
339
|
-
for index, run in enumerate(runs, 1):
|
|
344
|
+
for index, run in enumerate((current_run_facts(project_root, run) for run in runs), 1):
|
|
340
345
|
blocks.append(
|
|
341
346
|
f"\n## Run {index}\n\n"
|
|
342
347
|
+ _line("Run timestamp", run.get("runTimestamp"))
|
|
@@ -22,6 +22,7 @@ from okstra_project import read_task_key
|
|
|
22
22
|
from okstra_ctl.run_context import dir_flock
|
|
23
23
|
from okstra_ctl.final_report_paths import timeline_report_record_rel
|
|
24
24
|
from okstra_ctl.task_target import resolve_task_root, project_rel
|
|
25
|
+
from okstra_ctl.timeline_runs import current_run_facts
|
|
25
26
|
|
|
26
27
|
NOTE_KINDS = ("verification-evidence", "decision-draft", "analysis-note")
|
|
27
28
|
|
|
@@ -97,7 +98,12 @@ def rerun_readiness(project_root: Path, runs: list[dict]) -> dict | None:
|
|
|
97
98
|
|
|
98
99
|
|
|
99
100
|
def assemble_recap(task_root: Path, project_root: Path) -> dict:
|
|
100
|
-
|
|
101
|
+
# timeline 항목의 status / workflowSnapshot / reportRecordPath 는 준비 시점
|
|
102
|
+
# 값이다. 각 run 의 종료 상태는 그 run 의 run-manifest 에서 덮어쓴다.
|
|
103
|
+
runs = [
|
|
104
|
+
current_run_facts(project_root, run) if isinstance(run, dict) else run
|
|
105
|
+
for run in _load_timeline(task_root)
|
|
106
|
+
]
|
|
101
107
|
transitions = []
|
|
102
108
|
prev_phase = ""
|
|
103
109
|
latest_states: dict = {}
|
|
@@ -1962,6 +1962,19 @@ def _initialize_report_ledgers(ctx: Mapping[str, Any], manifest: Mapping[str, An
|
|
|
1962
1962
|
"activeClarifications": [],
|
|
1963
1963
|
"carriedDecisions": [],
|
|
1964
1964
|
})
|
|
1965
|
+
# 이월 결정은 여기서 심는다. 리드가 손으로 carry 하던 동안 대부분의
|
|
1966
|
+
# run 이 빈 채로 갔고, 리포트는 행 없는 C-NNN 을 인용했다.
|
|
1967
|
+
response_value = str(ctx.get("CLARIFICATION_RESPONSE_PATH") or "")
|
|
1968
|
+
if response_value:
|
|
1969
|
+
from .approval_decisions import seed_carried_decisions
|
|
1970
|
+
|
|
1971
|
+
seed_carried_decisions(
|
|
1972
|
+
approval_path,
|
|
1973
|
+
Path(response_value),
|
|
1974
|
+
source_run_ref=str(
|
|
1975
|
+
ctx.get("CLARIFICATION_RESPONSE_RELATIVE_PATH") or response_value
|
|
1976
|
+
),
|
|
1977
|
+
)
|
|
1965
1978
|
activity_value = str(ctx.get("LEAD_EVENTS_PATH") or "")
|
|
1966
1979
|
activity_path = Path(activity_value)
|
|
1967
1980
|
if activity_value and not activity_path.is_file():
|
|
@@ -199,6 +199,49 @@ def _anchorable(row_id: str) -> bool:
|
|
|
199
199
|
return bool(row_id) and " " not in row_id and "/" not in row_id
|
|
200
200
|
|
|
201
201
|
|
|
202
|
+
def _count_ids(value: object, counts: dict[str, int]) -> None:
|
|
203
|
+
if isinstance(value, dict):
|
|
204
|
+
for key in ("id", "activityId", "clarificationId"):
|
|
205
|
+
row_id = value.get(key)
|
|
206
|
+
if isinstance(row_id, str) and row_id:
|
|
207
|
+
counts[row_id] = counts.get(row_id, 0) + 1
|
|
208
|
+
for nested in value.values():
|
|
209
|
+
_count_ids(nested, counts)
|
|
210
|
+
elif isinstance(value, list):
|
|
211
|
+
for nested in value:
|
|
212
|
+
_count_ids(nested, counts)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _shared_task_block_ids(data: dict, omitted_fields: tuple[str, ...]) -> set[str]:
|
|
216
|
+
"""Ids several rows of the task block carry, so no single row owns them.
|
|
217
|
+
|
|
218
|
+
A direction's scope commitments are numbered `IC-001` … inside each
|
|
219
|
+
direction, and its planning invariants `PI-001` … likewise; the same id
|
|
220
|
+
sits in every ranked option and every audited candidate. A link to
|
|
221
|
+
`#id-IC-001` would land on whichever card came first, so the id stays
|
|
222
|
+
plain text. A clarification id the block repeats (a requirements report
|
|
223
|
+
lists `C-001` under two unresolved requirements) is not affected: the
|
|
224
|
+
clarification article is its one home and keeps the anchor.
|
|
225
|
+
"""
|
|
226
|
+
from ..report_contract import TASK_TYPE_DATA_PROPERTY
|
|
227
|
+
|
|
228
|
+
property_name = TASK_TYPE_DATA_PROPERTY.get(
|
|
229
|
+
(data.get("header") or {}).get("taskType", "")
|
|
230
|
+
)
|
|
231
|
+
block = data.get(property_name) if property_name else None
|
|
232
|
+
if not isinstance(block, dict):
|
|
233
|
+
return set()
|
|
234
|
+
counts: dict[str, int] = {}
|
|
235
|
+
_count_ids(
|
|
236
|
+
{key: value for key, value in block.items() if key not in omitted_fields},
|
|
237
|
+
counts,
|
|
238
|
+
)
|
|
239
|
+
owned_elsewhere: set[str] = set()
|
|
240
|
+
_collect_ids(data.get("clarificationItems", []), owned_elsewhere)
|
|
241
|
+
_collect_ids(data.get("agentActivity", []), owned_elsewhere)
|
|
242
|
+
return {row_id for row_id, n in counts.items() if n > 1} - owned_elsewhere
|
|
243
|
+
|
|
244
|
+
|
|
202
245
|
def anchor_index(data: dict, omitted_fields: tuple[str, ...] = ()) -> dict[str, str]:
|
|
203
246
|
"""Map every row a reader can reach to the anchor name that lands on it.
|
|
204
247
|
|
|
@@ -211,15 +254,28 @@ def anchor_index(data: dict, omitted_fields: tuple[str, ...] = ()) -> dict[str,
|
|
|
211
254
|
It stops there. `summary` is the AI-facing digest and
|
|
212
255
|
`analysisCommon.scope` describes the analysis target rather than listing
|
|
213
256
|
rows; neither renders, so a link to one would land nowhere.
|
|
257
|
+
|
|
258
|
+
Cross-check rows are anchored `id-xv-<id>` by the base template — the
|
|
259
|
+
prefix keeps a legacy consensus row still numbered `C-NNN` from sharing
|
|
260
|
+
an element id with the clarification of that number — so they carry that
|
|
261
|
+
name here, and a legacy row whose id a clarification already owns keeps
|
|
262
|
+
pointing at the clarification.
|
|
214
263
|
"""
|
|
215
|
-
found =
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
264
|
+
found = (
|
|
265
|
+
_own_section_ids(data, omitted_fields)
|
|
266
|
+
- _shared_task_block_ids(data, omitted_fields)
|
|
267
|
+
) | set(evidence_index(data, omitted_fields))
|
|
268
|
+
index = {
|
|
219
269
|
row_id: f"id-{row_id}"
|
|
220
270
|
for row_id in sorted(found)
|
|
221
271
|
if isinstance(row_id, str) and _anchorable(row_id)
|
|
222
272
|
}
|
|
273
|
+
for block in ("consensus", "differences"):
|
|
274
|
+
for row in _dig(data, ("crossVerification", block)):
|
|
275
|
+
row_id = row.get("id") if isinstance(row, dict) else None
|
|
276
|
+
if isinstance(row_id, str) and _anchorable(row_id) and row_id not in index:
|
|
277
|
+
index[row_id] = f"id-xv-{row_id}"
|
|
278
|
+
return index
|
|
223
279
|
|
|
224
280
|
|
|
225
281
|
def analysis_review_ids(data: dict) -> tuple[str, ...]:
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""Rows and links for the ids a report cites but does not define.
|
|
2
|
+
|
|
3
|
+
The human HTML renders from ``data.json``, and every id the record defines
|
|
4
|
+
gets an anchor there. Two id families are cited on almost every page and
|
|
5
|
+
defined on none of them:
|
|
6
|
+
|
|
7
|
+
* the brief's end-state ids (``EB-001``, ``PB-001``, ``EO-001``) — the
|
|
8
|
+
denominator of every requirement-coverage table. The sentence behind each
|
|
9
|
+
id exists only in the task brief; no phase repeats it.
|
|
10
|
+
* the clarification ids a previous run settled (``C-005``) — the record points
|
|
11
|
+
at that run through ``clarificationCarryIn.sourceFile`` and cites the ids
|
|
12
|
+
in its prose, but only the lead's ``carriedDecisions`` put a row for them
|
|
13
|
+
in this record, and a run that carried none leaves the ids as dead text.
|
|
14
|
+
|
|
15
|
+
Both homes are okstra-owned files inside the same task directory, so the
|
|
16
|
+
renderer follows the two pointers the run pinned — ``taskBriefPath`` is
|
|
17
|
+
always ``<task>/instruction-set/task-brief.md`` (``path_hints.py``), and the
|
|
18
|
+
carry-in record is named by the report itself — and only to give a cited id
|
|
19
|
+
a place to land. Neither read is a precondition: a missing or unreadable file
|
|
20
|
+
yields no rows and no links, never a render failure.
|
|
21
|
+
"""
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import os
|
|
25
|
+
import re
|
|
26
|
+
from collections.abc import Container
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
|
|
29
|
+
from ..json_boundary import JsonBoundaryError, load_owned_object
|
|
30
|
+
from ..report_view_artifacts import html_view_path
|
|
31
|
+
from ..scope_provenance import brief_end_state_rows
|
|
32
|
+
|
|
33
|
+
_USER_RESPONSE_RE = re.compile(r"^user-response-(?P<task_type>.+)-(?P<seq>\d{3,})\.md$")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _task_dir(data_path: Path) -> Path | None:
|
|
37
|
+
"""The task directory a report lives under, or None outside the layout.
|
|
38
|
+
|
|
39
|
+
A report sits at ``<task>/runs/<type>/reports/`` — or one level deeper
|
|
40
|
+
for an implementation stage, ``runs/implementation/stage-<N>/reports/`` —
|
|
41
|
+
so the nearest ``runs`` ancestor names the task directory either way.
|
|
42
|
+
"""
|
|
43
|
+
for parent in data_path.resolve().parents:
|
|
44
|
+
if parent.name == "runs":
|
|
45
|
+
return parent.parent
|
|
46
|
+
return None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _project_root(data_path: Path) -> Path | None:
|
|
50
|
+
"""The project root, or None when the report is not under ``.okstra/``.
|
|
51
|
+
|
|
52
|
+
Every okstra-owned artifact lives under ``<PROJECT_ROOT>/.okstra/``, and a
|
|
53
|
+
carry-in ``sourceFile`` is recorded relative to that root.
|
|
54
|
+
"""
|
|
55
|
+
for parent in data_path.resolve().parents:
|
|
56
|
+
if parent.name == ".okstra":
|
|
57
|
+
return parent.parent
|
|
58
|
+
return None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def brief_end_states(data_path: Path) -> list[dict[str, str]]:
|
|
62
|
+
"""The brief's end-state rows, in brief order, as template-ready dicts."""
|
|
63
|
+
task_dir = _task_dir(data_path)
|
|
64
|
+
if task_dir is None:
|
|
65
|
+
return []
|
|
66
|
+
brief = task_dir / "instruction-set" / "task-brief.md"
|
|
67
|
+
return [
|
|
68
|
+
{"id": row.id, "section": row.section, "statement": row.statement}
|
|
69
|
+
for row in brief_end_state_rows(brief)
|
|
70
|
+
]
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _carry_in_record(source: Path) -> Path | None:
|
|
74
|
+
"""The report record a carry-in pointer resolves to.
|
|
75
|
+
|
|
76
|
+
The pointer names either the prior run's record itself or the
|
|
77
|
+
user-responses sidecar exported from that run's page; the sidecar sits in
|
|
78
|
+
``runs/<type>/user-responses/`` beside the run's ``reports/`` directory
|
|
79
|
+
and carries the run's task type and seq in its name.
|
|
80
|
+
"""
|
|
81
|
+
if source.name.endswith(".data.json"):
|
|
82
|
+
return source
|
|
83
|
+
match = _USER_RESPONSE_RE.match(source.name)
|
|
84
|
+
if match is None or source.parent.name != "user-responses":
|
|
85
|
+
return None
|
|
86
|
+
task_type, seq = match.group("task_type"), match.group("seq")
|
|
87
|
+
return source.parent.parent / "reports" / f"final-report-{task_type}-{seq}.data.json"
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _clarification_ids(record: Path) -> list[str]:
|
|
91
|
+
"""The clarification ids the carry-in record defines, or none.
|
|
92
|
+
|
|
93
|
+
The record is read through the owned-JSON boundary like every report
|
|
94
|
+
record; a record that fails it is a record this page cannot link into,
|
|
95
|
+
not a reason to refuse this page.
|
|
96
|
+
"""
|
|
97
|
+
try:
|
|
98
|
+
payload = load_owned_object(record, artifact="carry-in report record")
|
|
99
|
+
except JsonBoundaryError:
|
|
100
|
+
return []
|
|
101
|
+
rows = payload.get("clarificationItems")
|
|
102
|
+
if not isinstance(rows, list):
|
|
103
|
+
return []
|
|
104
|
+
return [
|
|
105
|
+
row["id"]
|
|
106
|
+
for row in rows
|
|
107
|
+
if isinstance(row, dict) and isinstance(row.get("id"), str) and row["id"]
|
|
108
|
+
]
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def carry_in_links(
|
|
112
|
+
data: dict, data_path: Path, *, exclude: Container[str] = ()
|
|
113
|
+
) -> dict[str, str]:
|
|
114
|
+
"""Map each clarification id the carry-in record defines to its anchor
|
|
115
|
+
on that record's HTML page, as an href relative to this report's page.
|
|
116
|
+
|
|
117
|
+
``exclude`` names the ids this document already anchors — a row the lead
|
|
118
|
+
did carry keeps its in-page link, and the prior run's page is only for
|
|
119
|
+
the ids this page has no row for.
|
|
120
|
+
"""
|
|
121
|
+
carry_in = data.get("clarificationCarryIn")
|
|
122
|
+
source_value = carry_in.get("sourceFile") if isinstance(carry_in, dict) else None
|
|
123
|
+
if not isinstance(source_value, str) or not source_value.strip():
|
|
124
|
+
return {}
|
|
125
|
+
root = _project_root(data_path)
|
|
126
|
+
if root is None:
|
|
127
|
+
return {}
|
|
128
|
+
record = _carry_in_record(root / source_value.strip())
|
|
129
|
+
if record is None or not record.is_file():
|
|
130
|
+
return {}
|
|
131
|
+
page = html_view_path(record)
|
|
132
|
+
href = Path(os.path.relpath(page, data_path.resolve().parent)).as_posix()
|
|
133
|
+
return {
|
|
134
|
+
cid: f"{href}#id-{cid}"
|
|
135
|
+
for cid in _clarification_ids(record)
|
|
136
|
+
if cid not in exclude
|
|
137
|
+
}
|