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
|
@@ -30,36 +30,38 @@ def _anchors_pattern(keys: tuple[str, ...]) -> re.Pattern[str] | None:
|
|
|
30
30
|
)
|
|
31
31
|
|
|
32
32
|
|
|
33
|
-
def _link_ids(escaped: str,
|
|
34
|
-
"""
|
|
33
|
+
def _link_ids(escaped: str, links: dict) -> str:
|
|
34
|
+
"""Link the row ids inside text that is already escaped.
|
|
35
35
|
|
|
36
36
|
Running after the escape keeps this anchor the only markup in the result.
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
37
|
+
``links`` maps an id to the href that lands on it: ``#id-…`` for a row of
|
|
38
|
+
this page, or a relative page path plus fragment for a row the carry-in
|
|
39
|
+
report defines. Only ids with a link target become links — an id with no
|
|
40
|
+
row would point at a missing anchor, and a ticket number like
|
|
41
|
+
``DEV-10339`` has the same shape as a row id without being one.
|
|
40
42
|
"""
|
|
41
|
-
if not
|
|
43
|
+
if not links:
|
|
42
44
|
return escaped
|
|
43
|
-
pattern = _anchors_pattern(tuple(sorted(
|
|
45
|
+
pattern = _anchors_pattern(tuple(sorted(links)))
|
|
44
46
|
if pattern is None:
|
|
45
47
|
return escaped
|
|
46
48
|
|
|
47
49
|
def swap(match: re.Match[str]) -> str:
|
|
48
50
|
token = match.group(0)
|
|
49
|
-
|
|
50
|
-
return f'<a href="
|
|
51
|
+
href = links.get(token)
|
|
52
|
+
return f'<a href="{escape(href)}">{token}</a>' if href else token
|
|
51
53
|
|
|
52
54
|
return pattern.sub(swap, escaped)
|
|
53
55
|
|
|
54
56
|
|
|
55
|
-
def inline_code(value: object,
|
|
57
|
+
def inline_code(value: object, links: dict | None = None) -> Markup:
|
|
56
58
|
"""Convert paired backticks to ``<code>``, escaping everything else.
|
|
57
59
|
|
|
58
60
|
Workers author report prose with Markdown conventions, so an identifier
|
|
59
|
-
arrives wrapped in backticks. ``
|
|
61
|
+
arrives wrapped in backticks. ``links`` links the row ids cited in the
|
|
60
62
|
prose, inside backticks and out.
|
|
61
63
|
"""
|
|
62
|
-
index =
|
|
64
|
+
index = links or {}
|
|
63
65
|
text = "" if value is None else str(value)
|
|
64
66
|
out: list[str] = []
|
|
65
67
|
cursor = 0
|
|
@@ -84,7 +86,7 @@ def _grouped_paragraphs(text: str) -> list[str]:
|
|
|
84
86
|
]
|
|
85
87
|
|
|
86
88
|
|
|
87
|
-
def paragraphs(value: object,
|
|
89
|
+
def paragraphs(value: object, links: dict | None = None) -> Markup:
|
|
88
90
|
"""Split prose into ``<p>`` blocks, converting inline code inside each.
|
|
89
91
|
|
|
90
92
|
``userNarrative`` prose arrives as one unbroken run of sentences, so the
|
|
@@ -93,27 +95,27 @@ def paragraphs(value: object, anchors: dict | None = None) -> Markup:
|
|
|
93
95
|
"""
|
|
94
96
|
text = "" if value is None else str(value)
|
|
95
97
|
return Markup(
|
|
96
|
-
"".join(f"<p>{inline_code(block,
|
|
98
|
+
"".join(f"<p>{inline_code(block, links)}</p>" for block in _grouped_paragraphs(text))
|
|
97
99
|
)
|
|
98
100
|
|
|
99
101
|
|
|
100
|
-
def evidence_refs(refs: object,
|
|
101
|
-
"""Render a citation list,
|
|
102
|
+
def evidence_refs(refs: object, links: object) -> Markup:
|
|
103
|
+
"""Render a citation list, linking the entries that have a target.
|
|
102
104
|
|
|
103
105
|
The same index the prose links against decides here too — a citation and a
|
|
104
106
|
mid-sentence mention of the same id must land in the same place. No schema
|
|
105
107
|
behind this filter constrains an entry to an id: most of it is prose citing
|
|
106
108
|
a ``path:line``, which takes ``inline_code`` like every other prose cell.
|
|
107
109
|
"""
|
|
108
|
-
index =
|
|
110
|
+
index = links if isinstance(links, dict) else {}
|
|
109
111
|
items = refs if isinstance(refs, (list, tuple)) else []
|
|
110
112
|
out: list[str] = []
|
|
111
113
|
for ref in items:
|
|
112
|
-
|
|
113
|
-
if
|
|
114
|
+
href = index.get(str(ref))
|
|
115
|
+
if href is None:
|
|
114
116
|
out.append(str(inline_code(ref, index)))
|
|
115
117
|
continue
|
|
116
|
-
out.append(f'<a href="
|
|
118
|
+
out.append(f'<a href="{escape(href)}">{escape(str(ref))}</a>')
|
|
117
119
|
return Markup(", ".join(out))
|
|
118
120
|
|
|
119
121
|
|
|
@@ -17,6 +17,7 @@ from ..report_view_artifacts import user_responses_dir_for_report
|
|
|
17
17
|
from ..usage_cells import format_duration_ms
|
|
18
18
|
from ..json_boundary import load_owned_object
|
|
19
19
|
from .common import anchor_index
|
|
20
|
+
from .context_links import brief_end_states, carry_in_links
|
|
20
21
|
from .filters import (
|
|
21
22
|
code_evidence,
|
|
22
23
|
enum_label,
|
|
@@ -130,17 +131,26 @@ def render_v2_html_view(
|
|
|
130
131
|
env = Environment(loader=FileSystemLoader(str(root)), autoescape=select_autoescape(("html",)), undefined=StrictUndefined)
|
|
131
132
|
env.policies["json.dumps_kwargs"] = {"sort_keys": True, "ensure_ascii": False}
|
|
132
133
|
# Binding the index here is what lets a template cite an id without
|
|
133
|
-
# threading the index through every macro and call site.
|
|
134
|
-
|
|
134
|
+
# threading the index through every macro and call site. The record's
|
|
135
|
+
# own rows come first; the brief's end-state rows and the carry-in
|
|
136
|
+
# report's clarifications only fill ids this page has no row for.
|
|
137
|
+
links = {
|
|
138
|
+
row_id: f"#{name}"
|
|
139
|
+
for row_id, name in anchor_index(data, view.omitted_fields).items()
|
|
140
|
+
}
|
|
141
|
+
brief_rows = brief_end_states(data_path)
|
|
142
|
+
for row in brief_rows:
|
|
143
|
+
links.setdefault(row["id"], f"#id-{row['id']}")
|
|
144
|
+
links.update(carry_in_links(data, data_path, exclude=links))
|
|
135
145
|
chrome = load_dictionary(lang, HTML_DICTIONARY_REL)
|
|
136
146
|
translate = make_jinja_global(chrome)
|
|
137
147
|
env.globals["t"] = translate
|
|
138
148
|
env.filters["code_evidence"] = code_evidence
|
|
139
149
|
env.filters["enum_label"] = lambda value, vocabulary: enum_label(value, vocabulary, chrome)
|
|
140
150
|
env.filters["enum_legend"] = lambda vocabulary: enum_legend(vocabulary, chrome)
|
|
141
|
-
env.filters["evidence_refs"] = lambda refs: evidence_refs(refs,
|
|
142
|
-
env.filters["inline_code"] = lambda value: inline_code(value,
|
|
143
|
-
env.filters["paragraphs"] = lambda value: paragraphs(value,
|
|
151
|
+
env.filters["evidence_refs"] = lambda refs: evidence_refs(refs, links)
|
|
152
|
+
env.filters["inline_code"] = lambda value: inline_code(value, links)
|
|
153
|
+
env.filters["paragraphs"] = lambda value: paragraphs(value, links)
|
|
144
154
|
response_js = (root / "report.js").read_text(encoding="utf-8")
|
|
145
155
|
base_js = (root / "html/assets/base.js").read_text(encoding="utf-8")
|
|
146
156
|
source_data = final_report_data_path(Path(run_meta.source_report)).as_posix()
|
|
@@ -153,6 +163,9 @@ def render_v2_html_view(
|
|
|
153
163
|
"sourceData": source_data,
|
|
154
164
|
"dataSha256": _sha256(data_path),
|
|
155
165
|
"clarificationItems": data.get("clarificationItems", []),
|
|
166
|
+
# 브리프의 최종 상태 행. 모든 phase 가 EB/PB/EO 아이디를 인용하지만
|
|
167
|
+
# 문장은 브리프에만 있으므로 여기서 한 번 그려 인용에 착지점을 준다.
|
|
168
|
+
"briefEndStates": brief_rows,
|
|
156
169
|
# 합의·이견 근거는 태스크 본문과 같이 기본 화면에 올린다. 근거 대장
|
|
157
170
|
# 감사 모드에만 두면 판정이 사용자에게 안 보인다.
|
|
158
171
|
"crossVerification": data.get("crossVerification") or {},
|
package/runtime/python/okstra_ctl/report_html/view_models/implementation_option_selection.py
CHANGED
|
@@ -14,6 +14,13 @@ def build_implementation_option_selection_view(data: dict) -> HumanReportView:
|
|
|
14
14
|
selection if selection["mode"] == "candidate-comparison" else None
|
|
15
15
|
),
|
|
16
16
|
"narrative": selection["userNarrative"],
|
|
17
|
+
# 기준별 가중치. 옵션 카드의 점수 표가 기준 이름 옆에 가중치를 같이
|
|
18
|
+
# 보여야 가중 점수가 어떻게 나왔는지 읽힌다.
|
|
19
|
+
"criteriaWeights": {
|
|
20
|
+
criterion["criterion"]: criterion["weight"]
|
|
21
|
+
for criterion in selection.get("evaluationCriteria") or []
|
|
22
|
+
if isinstance(criterion, dict)
|
|
23
|
+
},
|
|
17
24
|
"recommendedOption": next(
|
|
18
25
|
(
|
|
19
26
|
option
|
|
@@ -198,6 +198,11 @@ class ReportSynthesisPacket:
|
|
|
198
198
|
"supplied one feasibility vote, at least "
|
|
199
199
|
f"{MIN_FEASIBLE_VOTES} votes are `feasible`, and both `safetyBlockers` "
|
|
200
200
|
"and `unresolvedFeasibilityFacts` are empty.",
|
|
201
|
+
"Each `feasibilityVotes` row states that analyser's own verdict, "
|
|
202
|
+
"rationale, and strongest counterevidence as its result gives them. "
|
|
203
|
+
"Two non-`uncertain` votes with identical rationale and "
|
|
204
|
+
"counterevidence are rejected as one worker's text copied under "
|
|
205
|
+
"another's name.",
|
|
201
206
|
f"Rank at most {MAX_RANKED_OPTIONS} valid options by descending "
|
|
202
207
|
"`requirement-fit`, `correctness-risk`, `architecture-fit`, "
|
|
203
208
|
"`weightedScore`, then id. With valid options set routing to "
|
|
@@ -30,6 +30,7 @@ PROSE_KEYS = frozenset({
|
|
|
30
30
|
"approach",
|
|
31
31
|
"approvalDisposition",
|
|
32
32
|
"approvalEvidence",
|
|
33
|
+
"architectureBoundaries",
|
|
33
34
|
"behavior",
|
|
34
35
|
"blastRadius",
|
|
35
36
|
"blockReason",
|
|
@@ -41,13 +42,16 @@ PROSE_KEYS = frozenset({
|
|
|
41
42
|
"change",
|
|
42
43
|
"check",
|
|
43
44
|
"claim",
|
|
45
|
+
"commitment",
|
|
44
46
|
"condition",
|
|
45
47
|
"confirmingSignal",
|
|
46
48
|
"conformanceExemption",
|
|
47
49
|
"consequences",
|
|
48
50
|
"constraint",
|
|
49
51
|
"context",
|
|
52
|
+
"coreMechanism",
|
|
50
53
|
"coreReason",
|
|
54
|
+
"counterevidence",
|
|
51
55
|
"decision",
|
|
52
56
|
"declinedFixRecommendations",
|
|
53
57
|
"description",
|
|
@@ -66,12 +70,15 @@ PROSE_KEYS = frozenset({
|
|
|
66
70
|
"exitContractSummary",
|
|
67
71
|
"expected",
|
|
68
72
|
"expectedBehaviorAfter",
|
|
73
|
+
"expectedChangeAreas",
|
|
69
74
|
"expectedForm",
|
|
70
75
|
"expectedOutcome",
|
|
71
76
|
"expectedResult",
|
|
72
77
|
"expectedVerification",
|
|
78
|
+
"fact",
|
|
73
79
|
"failureHandling",
|
|
74
80
|
"finalConclusion",
|
|
81
|
+
"goal",
|
|
75
82
|
"headline",
|
|
76
83
|
"howToStart",
|
|
77
84
|
"hypothesis",
|
|
@@ -134,6 +141,7 @@ PROSE_KEYS = frozenset({
|
|
|
134
141
|
"verification",
|
|
135
142
|
"verificationMethod",
|
|
136
143
|
"verificationSignal",
|
|
144
|
+
"whyItMatters",
|
|
137
145
|
"workingAssumption",
|
|
138
146
|
})
|
|
139
147
|
|
|
@@ -38,6 +38,7 @@ from okstra_project import project_json_path, upsert_project_json
|
|
|
38
38
|
from okstra_project.slug import slugify
|
|
39
39
|
from . import fix_cycles
|
|
40
40
|
from .analysis_packet import build_analysis_packet
|
|
41
|
+
from . import group_context
|
|
41
42
|
from .stage_ledger import (
|
|
42
43
|
build_stage_ledger,
|
|
43
44
|
render_stage_ledger,
|
|
@@ -1363,6 +1364,23 @@ def _validate_task_brief_preflight(
|
|
|
1363
1364
|
)
|
|
1364
1365
|
|
|
1365
1366
|
|
|
1367
|
+
def _validate_group_context_preflight(project_root: Path, task_group: str) -> None:
|
|
1368
|
+
"""그룹 맥락 문서가 있으면 검증한다. 없으면 선택적 입력이라 아무 일도 없다.
|
|
1369
|
+
|
|
1370
|
+
채우지 않은 뼈대가 워커에 닿으면 자리표시자를 배경으로 읽으므로, 있는데 결함이면
|
|
1371
|
+
prepare 를 멈추고 채우거나 지우라고 말한다."""
|
|
1372
|
+
path = group_context.group_context_file(project_root, task_group)
|
|
1373
|
+
if not path.is_file():
|
|
1374
|
+
return
|
|
1375
|
+
errors = group_context.validate_group_context(path, group_context.briefs_root(project_root))
|
|
1376
|
+
if errors:
|
|
1377
|
+
raise PrepareError(
|
|
1378
|
+
f"task-group context failed validation ({path}): "
|
|
1379
|
+
+ "; ".join(errors)
|
|
1380
|
+
+ " — fill the file, or delete it if the group needs no context"
|
|
1381
|
+
)
|
|
1382
|
+
|
|
1383
|
+
|
|
1366
1384
|
def _validate_prepare_inputs(project_root: Path, inp: PrepareInputs) -> list:
|
|
1367
1385
|
"""Validate pure prepare inputs and return a final-verification stage map."""
|
|
1368
1386
|
if not project_root.is_dir():
|
|
@@ -3599,6 +3617,14 @@ def _write_instruction_set_sources(
|
|
|
3599
3617
|
)
|
|
3600
3618
|
(instruction_set / "analysis-material.md").write_text(review_material, encoding="utf-8")
|
|
3601
3619
|
shutil.copyfile(inp.brief_path, instruction_set / "task-brief.md")
|
|
3620
|
+
# 그룹 맥락은 preflight 를 통과한 파일만 여기 온다(`_validate_group_context_preflight`).
|
|
3621
|
+
group_context_source = group_context.group_context_file(
|
|
3622
|
+
Path(inp.project_root), inp.task_group
|
|
3623
|
+
)
|
|
3624
|
+
group_context_path: Path | None = None
|
|
3625
|
+
if group_context_source.is_file():
|
|
3626
|
+
group_context_path = instruction_set / group_context.INSTRUCTION_SET_FILENAME
|
|
3627
|
+
shutil.copyfile(group_context_source, group_context_path)
|
|
3602
3628
|
# A rule that lives only in the conversation is one compaction away from
|
|
3603
3629
|
# gone, with no signal that it went. On disk it survives, and the session
|
|
3604
3630
|
# conformance check can ask afterwards whether it was read. The token is
|
|
@@ -3664,6 +3690,7 @@ def _write_instruction_set_sources(
|
|
|
3664
3690
|
task_key=ctx["TASK_KEY"],
|
|
3665
3691
|
task_type=ctx["TASK_TYPE"],
|
|
3666
3692
|
task_brief_path=instruction_set / "task-brief.md",
|
|
3693
|
+
group_context_path=group_context_path,
|
|
3667
3694
|
analysis_profile_path=instruction_set / "analysis-profile.md",
|
|
3668
3695
|
reference_expectations_path=instruction_set / "reference-expectations.md",
|
|
3669
3696
|
clarification_response_path=(
|
|
@@ -4547,6 +4574,7 @@ def prepare_task_bundle(inp: PrepareInputs) -> PrepareOutputs:
|
|
|
4547
4574
|
inp.brief_path,
|
|
4548
4575
|
assets.brief_validator,
|
|
4549
4576
|
)
|
|
4577
|
+
_validate_group_context_preflight(project_root, inp.task_group)
|
|
4550
4578
|
selected_direction = _resolve_planning_direction(inp)
|
|
4551
4579
|
if inp.task_type == "implementation":
|
|
4552
4580
|
ctx_stage_map = _prepare_implementation_approved_plan(inp)
|
|
@@ -33,7 +33,9 @@ _END_STATE_ID_RE = re.compile(r"^(?:EB|PB|EO)-\d{3}$")
|
|
|
33
33
|
_END_STATE_HEADINGS = frozenset(
|
|
34
34
|
{"Expected Behavior", "Preserved Behavior", "Expected Outcome"}
|
|
35
35
|
)
|
|
36
|
-
_END_STATE_BULLET_RE = re.compile(
|
|
36
|
+
_END_STATE_BULLET_RE = re.compile(
|
|
37
|
+
r"^-\s+(?P<id>(?:EB|PB|EO)-\d{3})\b\s*(?P<statement>.*)$"
|
|
38
|
+
)
|
|
37
39
|
# Only `## ` closes a section, mirroring validate-brief.py's section_body
|
|
38
40
|
# lookahead. `_BRIEF_HEADING_RE` above matches `#` through `######` and is for
|
|
39
41
|
# heading citations, which are checked against a different reader.
|
|
@@ -89,8 +91,21 @@ def brief_headings(brief_path: Path) -> set[str]:
|
|
|
89
91
|
return headings
|
|
90
92
|
|
|
91
93
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
+
@dataclass(frozen=True)
|
|
95
|
+
class BriefEndState:
|
|
96
|
+
"""One `- EB-001 <statement>` bullet of the brief's end-state sections."""
|
|
97
|
+
|
|
98
|
+
id: str
|
|
99
|
+
# The normalized `## ` heading the bullet sits under — one of
|
|
100
|
+
# `_END_STATE_HEADINGS`.
|
|
101
|
+
section: str
|
|
102
|
+
# The rest of the bullet line, verbatim. The brief is the only place this
|
|
103
|
+
# sentence exists; every phase cites the id and none of them repeats it.
|
|
104
|
+
statement: str
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def brief_end_state_rows(brief_path: Path) -> tuple[BriefEndState, ...]:
|
|
108
|
+
"""End-state rows the brief declares, preserving section and item order.
|
|
94
109
|
|
|
95
110
|
The reading rules mirror validators/validate-brief.py exactly, because that
|
|
96
111
|
file checks the very same lines for their format and the two readers must
|
|
@@ -116,7 +131,7 @@ def brief_end_state_id_sequence(brief_path: Path) -> tuple[str, ...]:
|
|
|
116
131
|
except OSError:
|
|
117
132
|
return ()
|
|
118
133
|
|
|
119
|
-
|
|
134
|
+
rows: list[BriefEndState] = []
|
|
120
135
|
section = ""
|
|
121
136
|
for line in text.splitlines():
|
|
122
137
|
if m := _SECTION_HEADING_RE.match(line):
|
|
@@ -126,8 +141,15 @@ def brief_end_state_id_sequence(brief_path: Path) -> tuple[str, ...]:
|
|
|
126
141
|
if not section:
|
|
127
142
|
continue
|
|
128
143
|
if m := _END_STATE_BULLET_RE.match(line.strip()):
|
|
129
|
-
|
|
130
|
-
|
|
144
|
+
rows.append(
|
|
145
|
+
BriefEndState(m.group("id"), section, m.group("statement").strip())
|
|
146
|
+
)
|
|
147
|
+
return tuple(rows)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def brief_end_state_id_sequence(brief_path: Path) -> tuple[str, ...]:
|
|
151
|
+
"""End-state ids the brief declares, preserving section and item order."""
|
|
152
|
+
return tuple(row.id for row in brief_end_state_rows(brief_path))
|
|
131
153
|
|
|
132
154
|
|
|
133
155
|
def brief_end_state_ids(brief_path: Path) -> set[str]:
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""timeline.json 의 run 항목을 그 run 의 현재 사실로 읽는 read-side 투영.
|
|
2
|
+
|
|
3
|
+
timeline 항목은 prepare 가 한 번 쓴다 — `render.render_timeline` 의 호출자는
|
|
4
|
+
`run._finalize_status_and_render_manifests` 뿐이다. run 이 끝날 때 상태를 쓰는
|
|
5
|
+
곳은 `validators/validate-run.py` 이고, 그 코드는 run-manifest(`status`,
|
|
6
|
+
`validation`, `workflowSnapshot`)와 task-manifest 만 고친다. 그래서 timeline
|
|
7
|
+
항목의 `status` 는 언제나 준비 시점 값(`prepared` / `in-progress`)이고
|
|
8
|
+
`workflowSnapshot` 은 준비 시점 스냅샷이다(2026-09-04 실측, dev-10626: 항목
|
|
9
|
+
6개 전부 `prepared`, 같은 run 의 run-manifest 4개는 `completed`). 같은 run 의
|
|
10
|
+
현재 사실은 그 run 의 run-manifest 가 가지므로, 읽는 쪽은 그것을 권위로 삼는다.
|
|
11
|
+
|
|
12
|
+
`reportRecordPath` 도 예약이다. 항목이 적는 값은 이번 run 의 기대 리포트 경로인데
|
|
13
|
+
리포트 seq 는 파일 존재로 배정되므로, 준비만 되고 돌지 않은 run 의 seq 를 다음
|
|
14
|
+
run 이 이어받는다(같은 실측: error-analysis run 001·002 가 같은
|
|
15
|
+
`final-report-error-analysis-001.data.json` 을 적었고 파일은 002 가 썼다).
|
|
16
|
+
validate-run 이 그 run 의 리포트를 검증했을 때(`validation.status` 가 `not-run`
|
|
17
|
+
이 아닐 때)만 그 경로가 그 run 의 리포트다.
|
|
18
|
+
|
|
19
|
+
task 단위 현재 사실(`currentStatus`, `latestRunStatus`, `workflow.*`,
|
|
20
|
+
`latestReportRecordPath`)은 task-manifest 가 권위다. 이 모듈은 run 단위만 다룬다.
|
|
21
|
+
"""
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
from typing import Any, Mapping
|
|
26
|
+
|
|
27
|
+
from .json_boundary import JsonBoundaryError, load_owned_object
|
|
28
|
+
from .paths import resolve_under_root
|
|
29
|
+
|
|
30
|
+
VALIDATION_NOT_RUN = "not-run"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def current_run_facts(project_root: Path, run: Mapping[str, Any]) -> dict[str, Any]:
|
|
34
|
+
"""timeline 항목에 그 run 의 run-manifest 가 기록한 현재 사실을 덮어쓴 사본.
|
|
35
|
+
|
|
36
|
+
- `status`, `workflowSnapshot`: run-manifest 값. 키가 없으면 항목 값 그대로.
|
|
37
|
+
- `reportRecordPath` / `reportPath`: run-manifest 의 `validation.status` 가
|
|
38
|
+
`not-run` 이면 비운다 — 그 run 은 리포트를 낸 적이 없고 경로는 예약이다.
|
|
39
|
+
`validation` 블록이 없는 매니페스트는 판정 근거가 없으므로 그대로 둔다.
|
|
40
|
+
|
|
41
|
+
run-manifest 가 없으면(`runManifestPath` 가 비었거나 파일이 없으면) 항목을
|
|
42
|
+
그대로 돌려준다. 있는데 읽을 수 없으면 `JsonBoundaryError` 가 그대로 올라간다
|
|
43
|
+
— 투영이 잘못된 파일을 조용히 건너뛰면 그 run 만 준비 시점 값으로 남는다.
|
|
44
|
+
"""
|
|
45
|
+
entry = dict(run)
|
|
46
|
+
manifest = _run_manifest(project_root, run)
|
|
47
|
+
if manifest is None:
|
|
48
|
+
return entry
|
|
49
|
+
status = manifest.get("status")
|
|
50
|
+
if isinstance(status, str) and status:
|
|
51
|
+
entry["status"] = status
|
|
52
|
+
snapshot = manifest.get("workflowSnapshot")
|
|
53
|
+
if isinstance(snapshot, Mapping):
|
|
54
|
+
entry["workflowSnapshot"] = dict(snapshot)
|
|
55
|
+
validation = manifest.get("validation")
|
|
56
|
+
if isinstance(validation, Mapping) and validation.get("status") == VALIDATION_NOT_RUN:
|
|
57
|
+
entry["reportRecordPath"] = ""
|
|
58
|
+
entry["reportPath"] = ""
|
|
59
|
+
return entry
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _run_manifest(project_root: Path, run: Mapping[str, Any]) -> Mapping[str, Any] | None:
|
|
63
|
+
relative = run.get("runManifestPath")
|
|
64
|
+
if not isinstance(relative, str) or not relative:
|
|
65
|
+
return None
|
|
66
|
+
path = resolve_under_root(project_root, relative)
|
|
67
|
+
if path is None:
|
|
68
|
+
raise JsonBoundaryError(Path(relative), "run manifest", "outside this project")
|
|
69
|
+
if not path.is_file():
|
|
70
|
+
return None
|
|
71
|
+
return load_owned_object(path, artifact="run manifest")
|
|
@@ -70,6 +70,7 @@ from okstra_ctl.role_requirements import (
|
|
|
70
70
|
)
|
|
71
71
|
from okstra_ctl.ids import slugify_task_segment
|
|
72
72
|
from okstra_ctl.brief_frontmatter import read_brief_frontmatter
|
|
73
|
+
from okstra_ctl.group_context import is_group_context_file
|
|
73
74
|
from okstra_ctl.analysis_inputs import (
|
|
74
75
|
ANALYSIS_TASK_TYPES,
|
|
75
76
|
AnalysisInputError,
|
|
@@ -2525,7 +2526,7 @@ def _suggest_group_briefs(state: WizardState, limit: int = 6) -> list[str]:
|
|
|
2525
2526
|
used_at_by_relpath = _recently_used_brief_times(state)
|
|
2526
2527
|
candidates: list[tuple[float, str]] = []
|
|
2527
2528
|
for path in root.rglob("*.md"):
|
|
2528
|
-
if not path.is_file():
|
|
2529
|
+
if not path.is_file() or is_group_context_file(path):
|
|
2529
2530
|
continue
|
|
2530
2531
|
tg_suggestion, _ = _brief_suggestions(path)
|
|
2531
2532
|
if tg_suggestion:
|
|
@@ -993,10 +993,38 @@ briefs saved (N):
|
|
|
993
993
|
next: /okstra-run (run a separate task-key per brief — use the filename stem as task-id)
|
|
994
994
|
```
|
|
995
995
|
|
|
996
|
+
### 7a. Task-group context (once per task-group)
|
|
997
|
+
|
|
998
|
+
Before printing the hand-off block, check whether
|
|
999
|
+
`<PROJECT_ROOT>/.okstra/briefs/<task-group>/group-context.md` exists. That
|
|
1000
|
+
file is the one document every task in the group shares — why the group
|
|
1001
|
+
exists, how its result is measured (with the denominator named), group-wide
|
|
1002
|
+
constraints, ticket relations — and no single brief can recover it
|
|
1003
|
+
(observed: a 15-brief group whose reason, "the origin keeps going down under
|
|
1004
|
+
attack traffic", appeared in none of them, so option selection measured a
|
|
1005
|
+
per-page ratio instead of origin load). If the file is absent, ask once via
|
|
1006
|
+
`AskUserQuestion`:
|
|
1007
|
+
|
|
1008
|
+
- `Create the group context skeleton now (Recommended for a new task-group)`
|
|
1009
|
+
→ run `okstra group-context init --project-root <PROJECT_ROOT> --task-group <task-group>`
|
|
1010
|
+
and echo its output verbatim. The skeleton carries one `<...>` placeholder
|
|
1011
|
+
line per section; the user fills it. `/okstra-run` refuses to prepare any
|
|
1012
|
+
task in the group while a placeholder line remains.
|
|
1013
|
+
- `Skip — this group needs no shared context`.
|
|
1014
|
+
|
|
1015
|
+
Never fill the skeleton yourself from the tickets: a ticket summary decides
|
|
1016
|
+
nothing, and the sections ask for what the tickets do not say. If the file
|
|
1017
|
+
exists, leave it untouched and mention its path in the hand-off block.
|
|
1018
|
+
|
|
996
1019
|
Then stop. Do not invoke `okstra-run` directly — the user chooses when to
|
|
997
1020
|
proceed, and they may want to edit the brief externally first. In the
|
|
998
1021
|
multi-brief case the user also decides the order in which tasks are
|
|
999
|
-
started.
|
|
1022
|
+
started. When a skeleton was created in 7a, add one line to the hand-off
|
|
1023
|
+
block:
|
|
1024
|
+
|
|
1025
|
+
```
|
|
1026
|
+
group context skeleton: <abs>/<task-group>/group-context.md (fill before /okstra-run)
|
|
1027
|
+
```
|
|
1000
1028
|
|
|
1001
1029
|
## Output Rules
|
|
1002
1030
|
|
|
@@ -36,6 +36,7 @@ When a user selects a specific task:
|
|
|
36
36
|
|
|
37
37
|
1. Run `okstra model-io history-input --project-root <projectRoot> --task-ref <task-key>`.
|
|
38
38
|
2. Use each numbered run block for chronological status, report, run-manifest, and resume paths.
|
|
39
|
+
`Status` is the run's current status from its own run-manifest (the timeline entry itself only records the prepare-time value). `Report` is `-` for a run that never validated a report — a prepared run that was superseded before it ran — so two runs never share one report in this list.
|
|
39
40
|
|
|
40
41
|
```markdown
|
|
41
42
|
## Runs for <task-key>
|
|
@@ -22,7 +22,7 @@ Use the CLI output as the source of truth:
|
|
|
22
22
|
okstra model-io recap-input --project-root <projectRoot> --task-ref <resolved-target>
|
|
23
23
|
```
|
|
24
24
|
|
|
25
|
-
Read the fixed-text `Run count` and repeated `Transition` blocks. Narrate each block's `From phase → To phase`, `Status`, `Last completed phase`, `Next phase`, and `Report` in the emitted chronological order. If `Run count: 0`, answer only "This task has no recorded runs." and do not claim to have read any file.
|
|
25
|
+
Read the fixed-text `Run count` and repeated `Transition` blocks. Narrate each block's `From phase → To phase`, `Status`, `Last completed phase`, `Next phase`, and `Report` in the emitted chronological order. `Status`, `Last completed phase`, and `Next phase` are each run's end state from its own run-manifest, so a `prepared` transition is a run that was prepared and never ran, and it carries no `Report`. If `Run count: 0`, answer only "This task has no recorded runs." and do not claim to have read any file.
|
|
26
26
|
|
|
27
27
|
`Next phase` is already a fixed scalar projection. Narrate it when non-empty, and otherwise use `Next phase status`: `pending` means that run did not settle the route, `blocked` means it stopped on something outside the run, and `terminal` means the lifecycle ended there.
|
|
28
28
|
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
---
|
|
2
|
+
type: group-context
|
|
3
|
+
task-group: <task-group>
|
|
4
|
+
created: <YYYY-MM-DD>
|
|
5
|
+
generator: okstra-brief-gen
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# Task-Group Context: <task-group>
|
|
9
|
+
|
|
10
|
+
> Shared by every task in this task-group. okstra copies it into each run's
|
|
11
|
+
> `instruction-set/task-group-context.md` and carries it in the analysis
|
|
12
|
+
> packet's `## Task-Group Context` section, ahead of the brief extract.
|
|
13
|
+
> Write only what no single ticket can recover — a summary of the tickets
|
|
14
|
+
> decides nothing. Preparation refuses this file while any `<...>`
|
|
15
|
+
> placeholder line remains; delete the file instead if the group needs none.
|
|
16
|
+
|
|
17
|
+
## Why This Group Exists
|
|
18
|
+
|
|
19
|
+
<The situation that created this group: what keeps happening, to whom, and why it takes a group of tickets rather than one. Not a summary of the tickets.>
|
|
20
|
+
|
|
21
|
+
## Definition of Better
|
|
22
|
+
|
|
23
|
+
<The measure the whole group is judged by, as a quantity and its direction. Name the denominator explicitly: a per-page ratio and a fleet-wide count are different measures, and a run that has only one of them will reason from it.>
|
|
24
|
+
|
|
25
|
+
## Group-Wide Constraints
|
|
26
|
+
|
|
27
|
+
<Rules every task in the group must respect, one bullet each, so no brief has to repeat them. Write `_(none)_` when there are none.>
|
|
28
|
+
|
|
29
|
+
## Ticket Relations
|
|
30
|
+
|
|
31
|
+
<Which tickets are causes, which are observation means, which depend on which. Write `_(none)_` when the briefs' Related Task Graph already says it all.>
|
|
@@ -31,6 +31,16 @@ nav.report-index a { color: inherit; }
|
|
|
31
31
|
.tone-high, .tone-important { border-left-color: #d94b4b; }
|
|
32
32
|
.tone-medium { border-left-color: #d79b25; }
|
|
33
33
|
.evidence-refs { color: GrayText; font-size: .85rem; }
|
|
34
|
+
.section-lede { max-width: 78ch; color: GrayText; margin-top: 0; }
|
|
35
|
+
/* A direction card reads as four labelled blocks — what, how, why, expected
|
|
36
|
+
outcome — so each block carries its own heading and the lists inside stay
|
|
37
|
+
tight enough that the card does not read as one long table. */
|
|
38
|
+
.option-detail { margin-top: 1rem; padding-top: .8rem; border-top: 1px solid color-mix(in srgb, CanvasText 12%, transparent); }
|
|
39
|
+
.option-detail h4 { margin: 0 0 .4rem; font-size: 1rem; }
|
|
40
|
+
.option-detail p { margin: .3rem 0; max-width: 78ch; }
|
|
41
|
+
.option-detail ul { margin: .2rem 0 .6rem; padding-left: 1.3rem; }
|
|
42
|
+
.option-detail table { margin-top: .5rem; }
|
|
43
|
+
.direction-goal { margin: -.4rem 0 .7rem 1.6rem; color: GrayText; font-size: .9rem; max-width: 78ch; }
|
|
34
44
|
figure { margin: 1rem 0 0; }
|
|
35
45
|
figcaption { display: flex; flex-wrap: wrap; justify-content: space-between; gap: .5rem; margin-bottom: .7rem; }
|
|
36
46
|
.visualization { overflow-x: auto; border-radius: 12px; background: color-mix(in srgb, CanvasText 4%, Canvas); }
|
|
@@ -44,6 +44,16 @@
|
|
|
44
44
|
</header>
|
|
45
45
|
<main id="main-content" data-report-role="human-main">
|
|
46
46
|
{% block human_content %}{% endblock %}
|
|
47
|
+
{% if briefEndStates %}
|
|
48
|
+
<section data-report-section="brief-end-states">
|
|
49
|
+
<h2>{{ t('base.brief-end-states') }}</h2>
|
|
50
|
+
<p class="section-lede">{{ t('base.brief-end-states-intro') }}</p>
|
|
51
|
+
<table>
|
|
52
|
+
<thead><tr><th scope="col">{{ t('macros.layout.id') }}</th><th scope="col">{{ t('base.brief-statement') }}</th></tr></thead>
|
|
53
|
+
<tbody>{% for row in briefEndStates %}<tr id="id-{{ row.id }}">{{ row_key(pairs=[(t('macros.layout.id'), row.id), (t('base.brief-section'), row.section | enum_label('briefSection'))]) }}<td>{{ row.statement | inline_code }}</td></tr>{% endfor %}</tbody>
|
|
54
|
+
</table>
|
|
55
|
+
</section>
|
|
56
|
+
{% endif %}
|
|
47
57
|
{% if crossVerification.get("consensus") or crossVerification.get("differences") %}
|
|
48
58
|
<section data-report-section="cross-check">
|
|
49
59
|
<h2>{{ t('base.cross-check') }}</h2>
|