okstra 0.225.1 → 0.226.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/dist/cli-registry.mjs +6 -0
- package/dist/cli-registry.mjs.map +1 -1
- package/dist/commands/execute/render-bundle.mjs +5 -0
- package/dist/commands/execute/render-bundle.mjs.map +1 -1
- package/docs/cli.md +17 -1
- package/docs/project-structure-overview.md +2 -0
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/bin/lib/okstra/cli.sh +5 -1
- package/runtime/bin/lib/okstra/globals.sh +1 -0
- package/runtime/bin/lib/okstra/usage.sh +3 -0
- package/runtime/bin/okstra.sh +2 -0
- package/runtime/prompts/duties/technical-verification-worker.json +2 -1
- package/runtime/prompts/lead/convergence.md +4 -1
- package/runtime/prompts/lead/phase-routing.md +7 -3
- package/runtime/prompts/lead/team-contract.md +1 -1
- package/runtime/prompts/wizard/prompts.ko.json +15 -0
- package/runtime/python/okstra_ctl/adapters/providers/codex/adapter.py +5 -0
- package/runtime/python/okstra_ctl/analysis_packet.py +108 -2
- package/runtime/python/okstra_ctl/approved_plan_stage_extract.py +206 -0
- package/runtime/python/okstra_ctl/brief_sources.py +200 -0
- package/runtime/python/okstra_ctl/business_flow/store.py +15 -2
- package/runtime/python/okstra_ctl/cmux.py +18 -3
- package/runtime/python/okstra_ctl/convergence_critic_prompt.py +4 -1
- package/runtime/python/okstra_ctl/design_surfaces.py +19 -7
- package/runtime/python/okstra_ctl/execution_mutation_audit.py +20 -18
- package/runtime/python/okstra_ctl/implementation_direction.py +10 -0
- package/runtime/python/okstra_ctl/initial_prompt_materialization.py +41 -0
- package/runtime/python/okstra_ctl/manager_split.py +185 -27
- package/runtime/python/okstra_ctl/next_phase.py +3 -1
- package/runtime/python/okstra_ctl/phases/final_verification/prior_blockers.py +128 -0
- package/runtime/python/okstra_ctl/phases/final_verification/profile.md +8 -5
- package/runtime/python/okstra_ctl/phases/final_verification/report_assets/final-verification.template.html +1 -1
- package/runtime/python/okstra_ctl/phases/final_verification/validation.py +84 -0
- package/runtime/python/okstra_ctl/phases/implementation/instructions/_implementation-executor.md +1 -1
- package/runtime/python/okstra_ctl/phases/implementation_option_selection/authoring.py +13 -5
- package/runtime/python/okstra_ctl/phases/implementation_option_selection/boundary.json +1 -1
- package/runtime/python/okstra_ctl/phases/implementation_option_selection/entry.py +67 -0
- package/runtime/python/okstra_ctl/phases/implementation_option_selection/profile.md +4 -2
- package/runtime/python/okstra_ctl/phases/implementation_option_selection/report_assets/implementation-option-selection.template.html +2 -1
- package/runtime/python/okstra_ctl/phases/implementation_option_selection/validation.py +127 -18
- package/runtime/python/okstra_ctl/phases/implementation_planning/profile.md +2 -1
- package/runtime/python/okstra_ctl/phases/technical_verification/profile.md +2 -2
- package/runtime/python/okstra_ctl/report_assembly.py +23 -2
- package/runtime/python/okstra_ctl/report_views.py +2 -1
- package/runtime/python/okstra_ctl/run.py +46 -1
- package/runtime/python/okstra_ctl/run_context.py +13 -8
- package/runtime/python/okstra_ctl/wizard/confirmation.py +6 -0
- package/runtime/python/okstra_ctl/wizard/ids.py +6 -0
- package/runtime/python/okstra_ctl/wizard/registry.py +21 -0
- package/runtime/python/okstra_ctl/wizard/render.py +4 -0
- package/runtime/python/okstra_ctl/wizard/sources.py +33 -11
- package/runtime/python/okstra_ctl/wizard/state.py +4 -0
- package/runtime/python/okstra_ctl/wizard/steps_options.py +74 -9
- package/runtime/python/okstra_ctl/worker_prompt_body.py +21 -1
- package/runtime/python/okstra_ctl/worker_prompt_headers.py +11 -4
- package/runtime/python/okstra_ctl/worker_prompt_policy.py +3 -0
- package/runtime/schemas/final-report-v2.0.schema.json +74 -0
- package/runtime/schemas/final-report-v3.0.schema.json +99 -2
- package/runtime/skills/okstra-brief-gen/SKILL.md +26 -0
- package/runtime/skills/okstra-manager/SKILL.md +15 -2
- package/runtime/templates/reports/brief.template.md +10 -1
- package/runtime/templates/reports/html/i18n/en.json +8 -0
- package/runtime/templates/reports/html/i18n/ko.json +8 -0
- package/runtime/templates/worker-prompt-preamble.md +1 -1
- package/runtime/validators/validate-brief.py +39 -1
- package/runtime/validators/validate-run.py +5 -0
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
"""Project an approved implementation-planning report onto one stage.
|
|
2
|
+
|
|
3
|
+
An implementation executor owns exactly one stage, but its prompt pointed it at
|
|
4
|
+
the whole plan record. Measured 2026-10-30 (fontradar-v2-api dev-11562 stage 1):
|
|
5
|
+
that record was 7,465 lines / 385 KB, 94 KB of it the planning phase's own
|
|
6
|
+
verification ledger, and a codex executor reported `contract-violated` because
|
|
7
|
+
its reads overran the CLI's tool-output limit before it made an edit.
|
|
8
|
+
|
|
9
|
+
The extract keeps everything the stage can act on and drops only what is named
|
|
10
|
+
in `OMITTED`, each with the reason, so a reader can tell an omission from a gap.
|
|
11
|
+
Rows of a row family keep the stage's rows plus every row that names no stage
|
|
12
|
+
(`plan_items._stage_scope` reads that as "applies to every stage"), and every
|
|
13
|
+
row carries the `P-*` id the plan-body verification and lead directives cite it
|
|
14
|
+
by. The full record stays the source of record behind `sourcePlanPath`.
|
|
15
|
+
"""
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import json
|
|
19
|
+
import re
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Any, Mapping
|
|
22
|
+
|
|
23
|
+
from .json_boundary import load_owned_object, write_owned_object_atomic
|
|
24
|
+
from .plan_items import PLAN_ITEM_SOURCES
|
|
25
|
+
|
|
26
|
+
EXTRACT_KIND = "approved-plan-stage-extract"
|
|
27
|
+
EXTRACT_SCHEMA_VERSION = 1
|
|
28
|
+
|
|
29
|
+
# implementationPlanning row families filtered to the stage. Every other
|
|
30
|
+
# implementationPlanning key is carried whole unless OMITTED names it.
|
|
31
|
+
_STAGE_ROW_FAMILIES = (
|
|
32
|
+
"requirementCoverage",
|
|
33
|
+
"validationChecklist",
|
|
34
|
+
"rollbackStrategy",
|
|
35
|
+
"dependencyMigrationRisk",
|
|
36
|
+
"crossProjectDependencies",
|
|
37
|
+
)
|
|
38
|
+
# plan_items numbers a row family by position (`P-Val-4` is the fourth
|
|
39
|
+
# validation row); a filtered list loses the position, so the id travels on the row.
|
|
40
|
+
_PLAN_ITEM_PREFIX = {
|
|
41
|
+
family: prefix for family, prefix, _ in PLAN_ITEM_SOURCES
|
|
42
|
+
if family in _STAGE_ROW_FAMILIES
|
|
43
|
+
}
|
|
44
|
+
# Top-level report keys an executor acts on. Everything else at the top level is
|
|
45
|
+
# report presentation, audit, or accounting and is listed in OMITTED.
|
|
46
|
+
_CARRIED_REPORT_KEYS = (
|
|
47
|
+
"clarificationItems",
|
|
48
|
+
"clarificationCarryIn",
|
|
49
|
+
"endStateCoverage",
|
|
50
|
+
"missingInformation",
|
|
51
|
+
"followUpTasks",
|
|
52
|
+
)
|
|
53
|
+
OMITTED = {
|
|
54
|
+
"implementationPlanning.planBodyVerification": (
|
|
55
|
+
"the planning phase's verification ledger (verdicts and dissent per plan "
|
|
56
|
+
"item); the plan body above is its settled result"
|
|
57
|
+
),
|
|
58
|
+
"implementationPlanning.designPreparation": (
|
|
59
|
+
"the stage-scoped effective decision is inlined in the executor prompt "
|
|
60
|
+
"under Effective design preparation"
|
|
61
|
+
),
|
|
62
|
+
"implementationPlanning.userNarrative": "plain-language explanation for the human reader",
|
|
63
|
+
"implementationPlanning.stages[other]": "stages this run does not own; their Stage Map rows stay",
|
|
64
|
+
"implementationPlanning.directionRealization.fileStructure[other]": (
|
|
65
|
+
"file rows whose path this stage's entry never names"
|
|
66
|
+
),
|
|
67
|
+
"<rows of another stage>": (
|
|
68
|
+
"requirementCoverage / validationChecklist / rollbackStrategy / "
|
|
69
|
+
"dependencyMigrationRisk / crossProjectDependencies rows whose stageRefs "
|
|
70
|
+
"exclude this stage and whose id this stage's entry never cites"
|
|
71
|
+
),
|
|
72
|
+
"humanSummary, verdictCard, rationale, summary, finalVerdict, recommendedNextSteps": (
|
|
73
|
+
"report presentation for the approver"
|
|
74
|
+
),
|
|
75
|
+
"crossVerification, agentActivity, evidence, executionStatus, executionRoles, tokenUsage": (
|
|
76
|
+
"planning-run audit and accounting"
|
|
77
|
+
),
|
|
78
|
+
"meta, frontmatter, header": "report identity",
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class StageExtractError(ValueError):
|
|
83
|
+
"""The approved plan cannot be projected onto the requested stage."""
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def stage_extract_payload(
|
|
87
|
+
report: Mapping[str, Any],
|
|
88
|
+
stage_number: int,
|
|
89
|
+
source_plan_path: str,
|
|
90
|
+
) -> dict[str, Any]:
|
|
91
|
+
planning = report.get("implementationPlanning")
|
|
92
|
+
if not isinstance(planning, Mapping):
|
|
93
|
+
raise StageExtractError("approved plan has no implementationPlanning object")
|
|
94
|
+
stage_entry = _stage_entry(planning, stage_number)
|
|
95
|
+
cited_text = json.dumps(stage_entry, ensure_ascii=False)
|
|
96
|
+
carried_planning: dict[str, Any] = {}
|
|
97
|
+
for key, value in planning.items():
|
|
98
|
+
if f"implementationPlanning.{key}" in OMITTED:
|
|
99
|
+
continue
|
|
100
|
+
if key == "stages":
|
|
101
|
+
carried_planning[key] = [stage_entry]
|
|
102
|
+
elif key in _STAGE_ROW_FAMILIES:
|
|
103
|
+
carried_planning[key] = _stage_rows(value, key, stage_number, cited_text)
|
|
104
|
+
elif key == "directionRealization" and isinstance(value, Mapping):
|
|
105
|
+
carried_planning[key] = _direction_realization(value, cited_text)
|
|
106
|
+
else:
|
|
107
|
+
carried_planning[key] = value
|
|
108
|
+
payload: dict[str, Any] = {
|
|
109
|
+
"kind": EXTRACT_KIND,
|
|
110
|
+
"schemaVersion": EXTRACT_SCHEMA_VERSION,
|
|
111
|
+
"sourcePlanPath": source_plan_path,
|
|
112
|
+
"stage": stage_number,
|
|
113
|
+
"planItemIds": (
|
|
114
|
+
"Rows carry their plan item id as `planItemId`. `P-Step-<stage>.<step>` "
|
|
115
|
+
"names step <step> of that stage's `stepwiseExecution`."
|
|
116
|
+
),
|
|
117
|
+
"omitted": dict(OMITTED),
|
|
118
|
+
"implementationPlanning": carried_planning,
|
|
119
|
+
}
|
|
120
|
+
for key in _CARRIED_REPORT_KEYS:
|
|
121
|
+
if key in report:
|
|
122
|
+
payload[key] = report[key]
|
|
123
|
+
return payload
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def materialize_stage_extract(
|
|
127
|
+
plan_path: Path,
|
|
128
|
+
stage_number: int,
|
|
129
|
+
output_path: Path,
|
|
130
|
+
) -> bool:
|
|
131
|
+
"""Write the extract for a `.data.json` plan; False when the plan is not one.
|
|
132
|
+
|
|
133
|
+
A Markdown plan predates the report record and has no structure to project,
|
|
134
|
+
and a missing file is reported by the prompt's own plan anchor, so neither
|
|
135
|
+
is an error here — the executor reads the plan as it did before.
|
|
136
|
+
"""
|
|
137
|
+
if not plan_path.name.endswith(".data.json") or not plan_path.is_file():
|
|
138
|
+
return False
|
|
139
|
+
report = load_owned_object(plan_path, artifact="approved implementation plan")
|
|
140
|
+
payload = stage_extract_payload(report, stage_number, str(plan_path))
|
|
141
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
142
|
+
write_owned_object_atomic(output_path, payload, artifact="approved plan stage extract")
|
|
143
|
+
return True
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _stage_entry(planning: Mapping[str, Any], stage_number: int) -> Mapping[str, Any]:
|
|
147
|
+
stages = planning.get("stages")
|
|
148
|
+
if not isinstance(stages, list):
|
|
149
|
+
raise StageExtractError("approved plan has no stages array")
|
|
150
|
+
for entry in stages:
|
|
151
|
+
if isinstance(entry, Mapping) and entry.get("stage") == stage_number:
|
|
152
|
+
return entry
|
|
153
|
+
raise StageExtractError(f"approved plan has no stage {stage_number}")
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _stage_rows(
|
|
157
|
+
rows: Any,
|
|
158
|
+
family: str,
|
|
159
|
+
stage_number: int,
|
|
160
|
+
cited_text: str,
|
|
161
|
+
) -> list[Any]:
|
|
162
|
+
if not isinstance(rows, list):
|
|
163
|
+
return rows
|
|
164
|
+
kept = []
|
|
165
|
+
for position, row in enumerate(rows, start=1):
|
|
166
|
+
if not isinstance(row, Mapping):
|
|
167
|
+
kept.append(row)
|
|
168
|
+
continue
|
|
169
|
+
if not _row_applies(row, stage_number, cited_text):
|
|
170
|
+
continue
|
|
171
|
+
prefix = _PLAN_ITEM_PREFIX.get(family)
|
|
172
|
+
kept.append(
|
|
173
|
+
{"planItemId": f"{prefix}-{position}", **row} if prefix else dict(row)
|
|
174
|
+
)
|
|
175
|
+
return kept
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _row_applies(row: Mapping[str, Any], stage_number: int, cited_text: str) -> bool:
|
|
179
|
+
refs = row.get("stageRefs")
|
|
180
|
+
stages = (
|
|
181
|
+
[value for value in refs if isinstance(value, int) and not isinstance(value, bool)]
|
|
182
|
+
if isinstance(refs, list) else []
|
|
183
|
+
)
|
|
184
|
+
if not stages or stage_number in stages:
|
|
185
|
+
return True
|
|
186
|
+
row_id = row.get("id")
|
|
187
|
+
return isinstance(row_id, str) and bool(row_id) and _cites(cited_text, row_id)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _direction_realization(
|
|
191
|
+
realization: Mapping[str, Any], cited_text: str
|
|
192
|
+
) -> dict[str, Any]:
|
|
193
|
+
carried = dict(realization)
|
|
194
|
+
rows = realization.get("fileStructure")
|
|
195
|
+
if isinstance(rows, list):
|
|
196
|
+
carried["fileStructure"] = [
|
|
197
|
+
row for row in rows
|
|
198
|
+
if not isinstance(row, Mapping)
|
|
199
|
+
or not isinstance(row.get("path"), str)
|
|
200
|
+
or row["path"] in cited_text
|
|
201
|
+
]
|
|
202
|
+
return carried
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _cites(text: str, identifier: str) -> bool:
|
|
206
|
+
return re.search(rf"(?<![\w-]){re.escape(identifier)}(?![\w-])", text) is not None
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
"""브리프 본문이 인용한 로컬 문서와 Source Material 항목을 맞춘다.
|
|
2
|
+
|
|
3
|
+
task worktree 는 체크아웃이라 프로젝트 루트의 git 미추적 파일(`.project-docs/` 스펙)이
|
|
4
|
+
없다. prepare 는 Source Material 의 `### Source N — file` 항목만 읽어 패킷에 싣으므로
|
|
5
|
+
(`analysis_packet.cited_documents_block`), 본문에만 이름이 나온 미추적 문서는 워커에게
|
|
6
|
+
닿지 않는다(fontradar dev-11562). `uncited_local_documents` 는 그런 경로를 찾고,
|
|
7
|
+
`validators/validate-brief.py` 가 이를 거절한다. `attach_missing_sources` 는 기존
|
|
8
|
+
브리프에 빠진 항목을 `format: by-reference` 로 덧붙인다. 다른 줄은 바꾸지 않는다.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import argparse
|
|
13
|
+
import datetime as dt
|
|
14
|
+
import re
|
|
15
|
+
import subprocess
|
|
16
|
+
import sys
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
from .analysis_packet import BY_REFERENCE_FORMAT, _fence_mask, _section_map, file_source_entries
|
|
20
|
+
|
|
21
|
+
SOURCE_MATERIAL = "Source Material"
|
|
22
|
+
ATTACH_FETCHED_VIA = "okstra brief-sources attach"
|
|
23
|
+
_HTML_COMMENT_RE = re.compile(r"<!--.*?-->", re.DOTALL)
|
|
24
|
+
_FRONTMATTER_RE = re.compile(r"\A---\n.*?\n---\n", re.DOTALL)
|
|
25
|
+
_TOKEN_RE = re.compile(r"[^\s`'\"()\[\]<>{},;|*]+")
|
|
26
|
+
_LINE_SUFFIX_RE = re.compile(r"(?::\d+(?:-\d+)?|#L\d+(?:-L?\d+)?)\Z")
|
|
27
|
+
_SOURCE_NUMBER_RE = re.compile(r"\A### Source (\d+)\b")
|
|
28
|
+
# okstra 자신의 산출물(브리프, 그룹 맥락, 보고서)은 okstra 가 직접 운반한다.
|
|
29
|
+
_OKSTRA_HOME = ".okstra"
|
|
30
|
+
# 문서만 본다. 미추적 코드 경로(`node_modules/` 타입 선언 등)는 설치로 다시 생기는 근거라
|
|
31
|
+
# 브리프의 출처 문서가 아니다.
|
|
32
|
+
DOCUMENT_SUFFIXES = frozenset({".md", ".markdown", ".txt", ".rst", ".adoc"})
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _body_lines(text: str) -> list[str]:
|
|
36
|
+
return _FRONTMATTER_RE.sub("", _HTML_COMMENT_RE.sub("", text), count=1).splitlines()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _project_file(token: str, root: Path) -> str | None:
|
|
40
|
+
"""토큰이 프로젝트 루트 아래 파일을 가리키면 루트 기준 posix 경로를 돌려준다."""
|
|
41
|
+
if "/" not in token or "://" in token:
|
|
42
|
+
return None
|
|
43
|
+
token = _LINE_SUFFIX_RE.sub("", token.rstrip(".:"))
|
|
44
|
+
candidate = Path(token).expanduser()
|
|
45
|
+
path = (candidate if candidate.is_absolute() else root / candidate).resolve()
|
|
46
|
+
try:
|
|
47
|
+
relative = path.relative_to(root)
|
|
48
|
+
except ValueError:
|
|
49
|
+
return None
|
|
50
|
+
if (not relative.parts or relative.parts[0] == _OKSTRA_HOME
|
|
51
|
+
or path.suffix.lower() not in DOCUMENT_SUFFIXES or not path.is_file()):
|
|
52
|
+
return None
|
|
53
|
+
return relative.as_posix()
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _cited_in_body(text: str, root: Path) -> list[str]:
|
|
57
|
+
paths: dict[str, None] = {}
|
|
58
|
+
lines = _body_lines(text)
|
|
59
|
+
section = ""
|
|
60
|
+
for line, fenced in zip(lines, _fence_mask(lines)):
|
|
61
|
+
if fenced:
|
|
62
|
+
continue
|
|
63
|
+
if line.startswith("## "):
|
|
64
|
+
section = line[3:].strip()
|
|
65
|
+
continue
|
|
66
|
+
if section == SOURCE_MATERIAL:
|
|
67
|
+
continue
|
|
68
|
+
for token in _TOKEN_RE.findall(line):
|
|
69
|
+
if (relative := _project_file(token, root)) is not None:
|
|
70
|
+
paths.setdefault(relative)
|
|
71
|
+
return list(paths)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _sourced(text: str, root: Path) -> set[str]:
|
|
75
|
+
source_material = _section_map(_HTML_COMMENT_RE.sub("", text)).get(SOURCE_MATERIAL, "")
|
|
76
|
+
return {
|
|
77
|
+
relative
|
|
78
|
+
for ref, _ in file_source_entries(source_material)
|
|
79
|
+
for token in _TOKEN_RE.findall(ref)
|
|
80
|
+
if (relative := _project_file(token, root)) is not None
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _git_tracked(root: Path, paths: list[str]) -> set[str] | None:
|
|
85
|
+
"""`paths` 중 git 이 추적하는 것. git 저장소가 아니면 None — worktree 가 없으니 검사할 것도 없다."""
|
|
86
|
+
proc = subprocess.run(
|
|
87
|
+
["git", "-C", str(root), "ls-files", "-z", "--", *paths],
|
|
88
|
+
capture_output=True,
|
|
89
|
+
text=True,
|
|
90
|
+
check=False,
|
|
91
|
+
)
|
|
92
|
+
if proc.returncode != 0:
|
|
93
|
+
return None
|
|
94
|
+
return {name for name in proc.stdout.split("\0") if name}
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def uncited_local_documents(text: str, project_root: Path) -> list[str]:
|
|
98
|
+
"""본문이 이름을 댄 git 미추적 프로젝트 파일 중 Source Material 의 file 항목이 없는 것."""
|
|
99
|
+
root = project_root.resolve()
|
|
100
|
+
cited = [path for path in _cited_in_body(text, root) if path not in _sourced(text, root)]
|
|
101
|
+
if not cited:
|
|
102
|
+
return []
|
|
103
|
+
tracked = _git_tracked(root, cited)
|
|
104
|
+
if tracked is None:
|
|
105
|
+
return []
|
|
106
|
+
return [path for path in cited if path not in tracked]
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _source_entry(index: int, ref: str, fetched_at: str) -> list[str]:
|
|
110
|
+
return [
|
|
111
|
+
f"### Source {index} — file",
|
|
112
|
+
"",
|
|
113
|
+
f"- ref: {ref}",
|
|
114
|
+
f"- fetched-via: {ATTACH_FETCHED_VIA}",
|
|
115
|
+
f"- fetched-at: {fetched_at}",
|
|
116
|
+
f"- format: {BY_REFERENCE_FORMAT}",
|
|
117
|
+
"",
|
|
118
|
+
"```",
|
|
119
|
+
"```",
|
|
120
|
+
"",
|
|
121
|
+
]
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def attach_missing_sources(brief_path: Path, project_root: Path, *, now: dt.datetime) -> list[str]:
|
|
125
|
+
"""빠진 file 항목을 Source Material 끝에 덧붙이고 덧붙인 경로를 돌려준다."""
|
|
126
|
+
text = brief_path.read_text(encoding="utf-8")
|
|
127
|
+
missing = uncited_local_documents(text, project_root)
|
|
128
|
+
if not missing:
|
|
129
|
+
return []
|
|
130
|
+
lines = text.split("\n")
|
|
131
|
+
mask = _fence_mask(lines)
|
|
132
|
+
start = next(
|
|
133
|
+
(i for i, (line, fenced) in enumerate(zip(lines, mask))
|
|
134
|
+
if not fenced and line.strip() == f"## {SOURCE_MATERIAL}"),
|
|
135
|
+
None,
|
|
136
|
+
)
|
|
137
|
+
if start is None:
|
|
138
|
+
raise ValueError(f"{brief_path}: no `## {SOURCE_MATERIAL}` section to attach to")
|
|
139
|
+
end = next(
|
|
140
|
+
(i for i in range(start + 1, len(lines)) if not mask[i] and lines[i].startswith("## ")),
|
|
141
|
+
len(lines),
|
|
142
|
+
)
|
|
143
|
+
numbers = [int(m.group(1)) for i in range(start + 1, end) if not mask[i]
|
|
144
|
+
if (m := _SOURCE_NUMBER_RE.match(lines[i]))]
|
|
145
|
+
while end > start + 1 and not lines[end - 1].strip():
|
|
146
|
+
end -= 1
|
|
147
|
+
stamp = now.strftime("%Y-%m-%d %H:%M")
|
|
148
|
+
entries: list[str] = [""]
|
|
149
|
+
for offset, ref in enumerate(missing, max(numbers, default=0) + 1):
|
|
150
|
+
entries.extend(_source_entry(offset, ref, stamp))
|
|
151
|
+
tail = lines[end:]
|
|
152
|
+
while tail and not tail[0].strip():
|
|
153
|
+
tail.pop(0)
|
|
154
|
+
brief_path.write_text("\n".join([*lines[:end], *entries, *tail]), encoding="utf-8")
|
|
155
|
+
return missing
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _attach_command(args: argparse.Namespace) -> int:
|
|
159
|
+
brief = Path(args.brief).resolve()
|
|
160
|
+
try:
|
|
161
|
+
added = attach_missing_sources(brief, Path(args.project_root), now=dt.datetime.now())
|
|
162
|
+
except ValueError as exc:
|
|
163
|
+
print(f"brief-sources: {exc}", file=sys.stderr)
|
|
164
|
+
return 2
|
|
165
|
+
if not added:
|
|
166
|
+
print(f"brief-sources: nothing to attach in {brief}")
|
|
167
|
+
return 0
|
|
168
|
+
print(f"brief-sources: attached {len(added)} Source Material entr{'y' if len(added) == 1 else 'ies'} to {brief}")
|
|
169
|
+
for path in added:
|
|
170
|
+
print(f" - {path} (format: {BY_REFERENCE_FORMAT})")
|
|
171
|
+
print("Every later prepare copies these files read-only into the analysis packet's `## Cited Local Documents`.")
|
|
172
|
+
return 0
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
176
|
+
parser = argparse.ArgumentParser(
|
|
177
|
+
prog="okstra brief-sources",
|
|
178
|
+
description=(
|
|
179
|
+
"Give every untracked project file a brief's body cites its own "
|
|
180
|
+
"Source Material entry, so runs copy it into the analysis packet."
|
|
181
|
+
),
|
|
182
|
+
)
|
|
183
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
184
|
+
attach = sub.add_parser(
|
|
185
|
+
"attach",
|
|
186
|
+
help="append a by-reference `### Source N — file` entry for each cited untracked file; edits nothing else",
|
|
187
|
+
)
|
|
188
|
+
attach.add_argument("--project-root", required=True, help="project root that holds `.okstra/`")
|
|
189
|
+
attach.add_argument("--brief", required=True, help="brief markdown file to upgrade in place")
|
|
190
|
+
attach.set_defaults(func=_attach_command)
|
|
191
|
+
return parser
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def main(argv: list[str] | None = None) -> int:
|
|
195
|
+
args = build_parser().parse_args(argv)
|
|
196
|
+
return args.func(args)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
if __name__ == "__main__":
|
|
200
|
+
sys.exit(main())
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import re
|
|
4
|
+
from collections import Counter
|
|
4
5
|
from pathlib import Path
|
|
5
6
|
from typing import Any, cast
|
|
6
7
|
|
|
@@ -27,6 +28,7 @@ from .contracts import (
|
|
|
27
28
|
from .source import candidate_projects, capture_project
|
|
28
29
|
|
|
29
30
|
CONFIRMED_LEVELS = frozenset({"static", "execution-verified"})
|
|
31
|
+
PACKET_CLAIM_LIMIT = 40
|
|
30
32
|
|
|
31
33
|
|
|
32
34
|
def task_link_path(project_root: Path, task_key: str) -> Path:
|
|
@@ -355,17 +357,22 @@ def knowledge_packet(
|
|
|
355
357
|
result = query_knowledge(project_root, question, source_root)
|
|
356
358
|
except (OSError, BusinessFlowError, JsonBoundaryError) as exc:
|
|
357
359
|
return f"## Shared Business Knowledge\n\nKnowledge unavailable: {exc}\n"
|
|
360
|
+
# 질문이 task-brief 전체라 토큰 매칭이 거의 모든 claim 을 고르므로, 선택 결과는
|
|
361
|
+
# 관련도 순위가 아니다. 이 프로젝트가 기록한 claim 을 앞세우고 나머지는 조회로 넘긴다.
|
|
362
|
+
own = str(project_root.resolve())
|
|
363
|
+
claims = sorted(result["claims"], key=lambda row: row["ownerRoot"] != own)
|
|
364
|
+
shown, omitted = claims[:PACKET_CLAIM_LIMIT], claims[PACKET_CLAIM_LIMIT:]
|
|
358
365
|
lines = [
|
|
359
366
|
"## Shared Business Knowledge",
|
|
360
367
|
"",
|
|
361
368
|
"Preserve level, freshness, conflict and provenance. Recheck historical facts before relying on them.",
|
|
362
369
|
"",
|
|
363
370
|
]
|
|
364
|
-
if not
|
|
371
|
+
if not claims:
|
|
365
372
|
lines.append(
|
|
366
373
|
"No related stored claims were selected. Inspect source and use additional lookup when new business terms emerge."
|
|
367
374
|
)
|
|
368
|
-
for row in
|
|
375
|
+
for row in shown:
|
|
369
376
|
lines.extend(
|
|
370
377
|
[
|
|
371
378
|
f"- `{row['id']}`: {row['value']}",
|
|
@@ -377,6 +384,12 @@ def knowledge_packet(
|
|
|
377
384
|
f" - Evidence: `{item['projectId']}:{item['path']}:{item['line']}`"
|
|
378
385
|
for item in row["evidence"]
|
|
379
386
|
)
|
|
387
|
+
if omitted:
|
|
388
|
+
counts = Counter(row["business"] for row in omitted)
|
|
389
|
+
lines.append(
|
|
390
|
+
f"- {len(omitted)} more selected claims are omitted; look them up by business: "
|
|
391
|
+
+ ", ".join(f"`{name}` ({count})" for name, count in counts.most_common())
|
|
392
|
+
)
|
|
380
393
|
lines.extend(f"- Knowledge read error: {error}" for error in result["errors"])
|
|
381
394
|
lines.extend(
|
|
382
395
|
[
|
|
@@ -12,6 +12,7 @@ import shlex
|
|
|
12
12
|
import shutil
|
|
13
13
|
import socket
|
|
14
14
|
import subprocess
|
|
15
|
+
import time
|
|
15
16
|
from dataclasses import dataclass
|
|
16
17
|
from pathlib import Path
|
|
17
18
|
from typing import Any, Collection, Mapping, Sequence
|
|
@@ -73,6 +74,13 @@ SOCKET_UNREACHABLE = "unreachable"
|
|
|
73
74
|
|
|
74
75
|
SOCKET_PROBE_TIMEOUT_SECONDS = 2
|
|
75
76
|
|
|
77
|
+
# 막 분할한 창에 보낸 `respawn-pane` 이 답 없이 소켓이 닫히는 경우가 있다. 실측
|
|
78
|
+
# 2026-10-29, dev-11562 두 run: 탭 이름은 붙었는데 창에는 첫 로그인 셸만 남았다.
|
|
79
|
+
# 그 창은 워커를 시작하지 않았으므로 같은 명령을 다시 보내도 워커가 둘이 되지 않는다.
|
|
80
|
+
TRANSIENT_SOCKET_ERROR = "Socket closed before reply"
|
|
81
|
+
RESPAWN_ATTEMPTS = 3
|
|
82
|
+
RESPAWN_RETRY_DELAY_SECONDS = 0.5
|
|
83
|
+
|
|
76
84
|
# cmux 가 돌려주는 식별자가 UUID 인지 ref 인지 가리는 자리. ref 는 번호가 다시
|
|
77
85
|
# 매겨지므로 원장에 남길 수 없다.
|
|
78
86
|
_UUID_PATTERN = re.compile(r"[0-9A-Fa-f]{8}(?:-[0-9A-Fa-f]{4}){3}-[0-9A-Fa-f]{12}")
|
|
@@ -851,9 +859,16 @@ def _exec_worker(surface_uuid: str, *, cwd: Path, command: Sequence[str]) -> Non
|
|
|
851
859
|
line = worker_command_line(
|
|
852
860
|
cwd=cwd, argv=command, path_value=shim_free_worker_path()
|
|
853
861
|
)
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
862
|
+
for attempt in range(1, RESPAWN_ATTEMPTS + 1):
|
|
863
|
+
started = run_cmux(["respawn-pane", "--surface", surface_uuid, "--command", line])
|
|
864
|
+
if started.returncode == 0:
|
|
865
|
+
return
|
|
866
|
+
error = started.stderr.strip()
|
|
867
|
+
if TRANSIENT_SOCKET_ERROR not in error or attempt == RESPAWN_ATTEMPTS:
|
|
868
|
+
raise RuntimeError(
|
|
869
|
+
f"respawn-pane: {error or 'cmux could not start the worker'}"
|
|
870
|
+
)
|
|
871
|
+
time.sleep(RESPAWN_RETRY_DELAY_SECONDS)
|
|
857
872
|
|
|
858
873
|
|
|
859
874
|
def _size_lead_pane(
|
|
@@ -296,7 +296,10 @@ verifiers may have missed:
|
|
|
296
296
|
- requirements / acceptance points with no covering evidence,
|
|
297
297
|
- DB / IO / SQL changes lacking real-execution evidence,
|
|
298
298
|
- regressions or broken error paths,
|
|
299
|
-
- scope / contract violations
|
|
299
|
+
- scope / contract violations,
|
|
300
|
+
- other locations or cases of a mechanism the analysers already raised (for
|
|
301
|
+
example the same stale-copy defect in a sibling editor, or after the version
|
|
302
|
+
changed) — a cell of that class they did not judge is a new candidate.
|
|
300
303
|
A finding that would not stop the release is not a blocker — say so, and it is
|
|
301
304
|
recorded as a conditional-acceptance condition instead. Do NOT restate an existing
|
|
302
305
|
Acceptance Blocker. If you find none, write `No candidate blockers.`
|
|
@@ -242,12 +242,24 @@ def _split_declared_paths(raw: str) -> list[str]:
|
|
|
242
242
|
return parts
|
|
243
243
|
|
|
244
244
|
|
|
245
|
-
def
|
|
245
|
+
def _step_path_cells(step: Mapping[str, Any]) -> list[str]:
|
|
246
|
+
"""step 이 건드리는 경로를 담은 칸들 — 독자용 `files` 와 쓰기 원장 `plannedPaths`.
|
|
247
|
+
|
|
248
|
+
`files` 는 문장이어도 되는 칸이라(profile §Stepwise Execution Order), 경로를
|
|
249
|
+
`plannedPaths` 에만 적은 계획도 스테이지에 매핑돼야 한다.
|
|
250
|
+
"""
|
|
246
251
|
raw = step.get("files")
|
|
247
252
|
if isinstance(raw, list):
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
253
|
+
cells = [_normalise(item) for item in raw if item]
|
|
254
|
+
else:
|
|
255
|
+
text = _normalise(raw)
|
|
256
|
+
cells = [text] if text else []
|
|
257
|
+
planned = step.get("plannedPaths")
|
|
258
|
+
for item in planned if isinstance(planned, list) else ():
|
|
259
|
+
path = _normalise(item).strip().strip("`") if isinstance(item, str) else ""
|
|
260
|
+
if path:
|
|
261
|
+
cells.append(path)
|
|
262
|
+
return cells
|
|
251
263
|
|
|
252
264
|
|
|
253
265
|
def _files_cell_tokens(cell: str) -> list[str]:
|
|
@@ -282,7 +294,7 @@ def _stages_touching_path(
|
|
|
282
294
|
cell
|
|
283
295
|
for step in stage.get("stepwiseExecution") or []
|
|
284
296
|
if isinstance(step, Mapping)
|
|
285
|
-
for cell in
|
|
297
|
+
for cell in _step_path_cells(step)
|
|
286
298
|
]
|
|
287
299
|
if any(_cell_covers_path(cell, path) for cell in cells):
|
|
288
300
|
hits.append(stage_number)
|
|
@@ -293,7 +305,7 @@ def _stages_for_selected_path(
|
|
|
293
305
|
path: str,
|
|
294
306
|
stages: Mapping[int, Mapping[str, Any]],
|
|
295
307
|
) -> list[int]:
|
|
296
|
-
"""Every stage whose stepwise ``files`` cells touch *path*.
|
|
308
|
+
"""Every stage whose stepwise ``files`` or ``plannedPaths`` cells touch *path*.
|
|
297
309
|
|
|
298
310
|
A file legitimately edited by more than one stage — the task-level
|
|
299
311
|
``qa/conformance-manifest.json`` is the standard case, since each
|
|
@@ -321,7 +333,7 @@ def _stages_for_selected_path(
|
|
|
321
333
|
hits = _stages_touching_path(part, stages)
|
|
322
334
|
if not hits and "qa/scripts/" in part:
|
|
323
335
|
# 적합성 스크립트는 executor 가 역할 권한으로 쓴다(write_policy
|
|
324
|
-
# `_role_qa_artifact_paths`). 계획 step 의 files 칸에는 원래 없다.
|
|
336
|
+
# `_role_qa_artifact_paths`). 계획 step 의 files·plannedPaths 칸에는 원래 없다.
|
|
325
337
|
continue
|
|
326
338
|
if not hits:
|
|
327
339
|
raise DesignSurfaceError(
|
|
@@ -667,26 +667,34 @@ def _generated_paths(policies: Sequence[WritePolicy]) -> frozenset[str]:
|
|
|
667
667
|
|
|
668
668
|
|
|
669
669
|
def _git_projection(root: Path) -> dict[str, Any]:
|
|
670
|
+
# 스냅샷은 워커 실행마다 두 번 찍힌다. rev-parse 는 여러 질의를 한 프로세스에서
|
|
671
|
+
# 줄 단위로 답하므로 질의마다 git 을 띄우지 않는다.
|
|
670
672
|
probe = subprocess.run(
|
|
671
|
-
["git", "-C", str(root), "rev-parse", "--is-inside-work-tree"
|
|
673
|
+
["git", "-C", str(root), "rev-parse", "--is-inside-work-tree",
|
|
674
|
+
"--path-format=absolute", "--git-dir", "--git-common-dir",
|
|
675
|
+
"--git-path", "index"],
|
|
672
676
|
capture_output=True,
|
|
673
677
|
text=True,
|
|
674
678
|
check=False,
|
|
675
679
|
)
|
|
676
680
|
if probe.returncode != 0:
|
|
677
681
|
return {"available": False}
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
+
lines = probe.stdout.splitlines()
|
|
683
|
+
if len(lines) == 4:
|
|
684
|
+
_inside, git_dir, common_dir, index_path = (Path(line) for line in lines)
|
|
685
|
+
else:
|
|
686
|
+
# 경로 속 줄바꿈은 질의 사이의 줄 경계와 구별되지 않는다. 그때만 경로를 하나씩 묻는다.
|
|
687
|
+
git_dir, common_dir, index_path = (
|
|
688
|
+
Path(_git_output(root, "rev-parse", "--path-format=absolute", *query))
|
|
689
|
+
for query in (("--git-dir",), ("--git-common-dir",), ("--git-path", "index"))
|
|
690
|
+
)
|
|
682
691
|
branch_ref = _git_output(root, "symbolic-ref", "-q", "HEAD")
|
|
683
|
-
head = _git_output(root, "rev-parse", "HEAD")
|
|
684
|
-
index_path = Path(_git_output(root, "rev-parse", "--path-format=absolute", "--git-path", "index"))
|
|
692
|
+
head, branch_commit = _git_output(root, "rev-parse", "HEAD", branch_ref).splitlines()
|
|
685
693
|
return {
|
|
686
694
|
"available": True,
|
|
687
695
|
"head": head,
|
|
688
696
|
"allowedBranchRef": branch_ref,
|
|
689
|
-
"branchCommit":
|
|
697
|
+
"branchCommit": branch_commit,
|
|
690
698
|
"gitDir": str(git_dir.resolve()),
|
|
691
699
|
"gitCommonDir": str(common_dir.resolve()),
|
|
692
700
|
"indexDigest": _optional_file_digest(index_path),
|
|
@@ -879,21 +887,15 @@ def _branch_switch_paths(
|
|
|
879
887
|
|
|
880
888
|
|
|
881
889
|
def _git_head(root: Path) -> str | None:
|
|
882
|
-
probe = subprocess.run(
|
|
883
|
-
["git", "-C", str(root), "rev-parse", "--is-inside-work-tree"],
|
|
884
|
-
capture_output=True,
|
|
885
|
-
text=True,
|
|
886
|
-
check=False,
|
|
887
|
-
)
|
|
888
|
-
if probe.returncode != 0:
|
|
889
|
-
return None
|
|
890
890
|
head = subprocess.run(
|
|
891
|
-
["git", "-C", str(root), "rev-parse", "HEAD"],
|
|
891
|
+
["git", "-C", str(root), "rev-parse", "--is-inside-work-tree", "HEAD"],
|
|
892
892
|
capture_output=True,
|
|
893
893
|
text=True,
|
|
894
894
|
check=False,
|
|
895
895
|
)
|
|
896
|
-
|
|
896
|
+
if head.returncode != 0:
|
|
897
|
+
return None
|
|
898
|
+
return head.stdout.splitlines()[-1].strip() or None
|
|
897
899
|
|
|
898
900
|
|
|
899
901
|
def _allowed_artifact_paths(policies: Sequence[WritePolicy]) -> set[str]:
|
|
@@ -67,6 +67,8 @@ class SelectedDirection:
|
|
|
67
67
|
direction: dict[str, Any]
|
|
68
68
|
requirement_coverage: tuple[dict[str, Any], ...]
|
|
69
69
|
planning_invariants: tuple[dict[str, Any], ...]
|
|
70
|
+
deferred_feasibility_facts: tuple[dict[str, Any], ...] = ()
|
|
71
|
+
split_acceptance: dict[str, Any] | None = None
|
|
70
72
|
|
|
71
73
|
|
|
72
74
|
@dataclass(frozen=True)
|
|
@@ -400,6 +402,8 @@ def resolve_selected_direction(
|
|
|
400
402
|
direction=_direction_payload(option),
|
|
401
403
|
requirement_coverage=tuple(_copy_json(option.get("requirementCoverage") or ())),
|
|
402
404
|
planning_invariants=tuple(_copy_json(option.get("planningInvariants") or ())),
|
|
405
|
+
deferred_feasibility_facts=tuple(_copy_json(option.get("unresolvedFeasibilityFacts") or ())),
|
|
406
|
+
split_acceptance=_copy_json(option["splitAcceptance"]) if "splitAcceptance" in option else None,
|
|
403
407
|
)
|
|
404
408
|
|
|
405
409
|
|
|
@@ -424,6 +428,12 @@ def write_selected_direction_snapshot(
|
|
|
424
428
|
"requirementCoverage": list(direction.requirement_coverage),
|
|
425
429
|
"planningInvariants": list(direction.planning_invariants),
|
|
426
430
|
}
|
|
431
|
+
# 방향이 유효한 채로 남긴 사실(이후 게이트에서 닫힐 사실, 사용자가 감수한
|
|
432
|
+
# 위험)은 계획이 일정에 올려야 하므로 스냅샷에 실어 보낸다.
|
|
433
|
+
if direction.deferred_feasibility_facts:
|
|
434
|
+
payload["deferredFeasibilityFacts"] = list(direction.deferred_feasibility_facts)
|
|
435
|
+
if direction.split_acceptance is not None:
|
|
436
|
+
payload["splitAcceptance"] = direction.split_acceptance
|
|
427
437
|
write_owned_object_atomic(
|
|
428
438
|
destination,
|
|
429
439
|
payload,
|