okstra 0.49.0 → 0.51.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.kr.md +8 -7
- package/README.md +8 -7
- package/bin/okstra +2 -0
- package/docs/kr/architecture.md +23 -24
- package/docs/kr/cli.md +6 -6
- package/docs/project-structure-overview.md +13 -9
- package/docs/superpowers/plans/2026-06-05-wizard-batch-prompts.md +559 -0
- package/docs/superpowers/specs/2026-06-05-wizard-batch-prompts-design.md +121 -0
- package/docs/task-process/error-analysis.md +1 -1
- package/docs/task-process/final-verification.md +1 -1
- package/docs/task-process/release-handoff.md +1 -1
- package/docs/task-process/requirements-discovery.md +1 -1
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/agents/SKILL.md +18 -14
- package/runtime/agents/workers/claude-worker.md +4 -4
- package/runtime/agents/workers/codex-worker.md +3 -3
- package/runtime/agents/workers/gemini-worker.md +3 -3
- package/runtime/agents/workers/report-writer-worker.md +3 -3
- package/runtime/bin/lib/okstra/cli.sh +8 -1
- package/runtime/bin/lib/okstra/globals.sh +3 -0
- package/runtime/bin/lib/okstra/interactive.sh +14 -12
- package/runtime/bin/lib/okstra/usage.sh +6 -0
- package/runtime/bin/okstra-render-report-views.py +1 -1
- package/runtime/bin/okstra-team-reconcile.sh +28 -0
- package/runtime/bin/okstra.sh +2 -0
- package/runtime/prompts/launch.template.md +4 -2
- package/runtime/prompts/profiles/_common-contract.md +15 -15
- package/runtime/prompts/profiles/_implementation-deliverable.md +1 -1
- package/runtime/prompts/profiles/_implementation-executor.md +3 -3
- package/runtime/prompts/profiles/_implementation-verifier.md +2 -2
- package/runtime/prompts/profiles/error-analysis.md +1 -1
- package/runtime/prompts/profiles/final-verification.md +2 -2
- package/runtime/prompts/profiles/implementation-planning.md +10 -9
- package/runtime/prompts/profiles/implementation.md +1 -1
- package/runtime/prompts/profiles/improvement-discovery.md +5 -5
- package/runtime/prompts/profiles/release-handoff.md +2 -2
- package/runtime/prompts/profiles/requirements-discovery.md +2 -2
- package/runtime/python/okstra_ctl/analysis_packet.py +259 -0
- package/runtime/python/okstra_ctl/clarification_items.py +11 -11
- package/runtime/python/okstra_ctl/context_cost.py +308 -0
- package/runtime/python/okstra_ctl/migrate.py +2 -12
- package/runtime/python/okstra_ctl/paths.py +22 -0
- package/runtime/python/okstra_ctl/render.py +285 -126
- package/runtime/python/okstra_ctl/render_final_report.py +32 -1
- package/runtime/python/okstra_ctl/report_views.py +12 -12
- package/runtime/python/okstra_ctl/run.py +510 -248
- package/runtime/python/okstra_ctl/sequence.py +2 -5
- package/runtime/python/okstra_ctl/team_reconcile.py +131 -0
- package/runtime/python/okstra_ctl/wizard.py +219 -136
- package/runtime/python/okstra_ctl/workflow.py +1 -1
- package/runtime/python/okstra_ctl/worktree.py +13 -5
- package/runtime/schemas/final-report-v1.0.schema.json +4 -0
- package/runtime/skills/okstra-brief/SKILL.md +1 -1
- package/runtime/skills/okstra-coding-preflight/SKILL.md +69 -0
- package/runtime/skills/okstra-coding-preflight/architecture/hexagonal.md +116 -0
- package/runtime/skills/okstra-coding-preflight/clean-code.md +254 -0
- package/runtime/skills/okstra-coding-preflight/languages/java.md +64 -0
- package/runtime/skills/okstra-coding-preflight/languages/javascript-typescript.md +87 -0
- package/runtime/skills/okstra-coding-preflight/languages/kotlin.md +69 -0
- package/runtime/skills/okstra-coding-preflight/languages/nodejs.md +66 -0
- package/runtime/skills/okstra-coding-preflight/languages/python.md +179 -0
- package/runtime/skills/okstra-coding-preflight/languages/rust.md +105 -0
- package/runtime/skills/okstra-coding-preflight/languages/sql.md +68 -0
- package/runtime/skills/okstra-context-loader/SKILL.md +12 -6
- package/runtime/skills/okstra-convergence/SKILL.md +8 -8
- package/runtime/skills/okstra-inspect/SKILL.md +100 -1
- package/runtime/skills/okstra-report-writer/SKILL.md +27 -23
- package/runtime/skills/okstra-run/SKILL.md +3 -1
- package/runtime/skills/okstra-team-contract/SKILL.md +8 -5
- package/runtime/templates/reports/final-report.template.md +188 -187
- package/runtime/templates/reports/i18n/en.json +4 -4
- package/runtime/templates/reports/i18n/ko.json +4 -4
- package/runtime/templates/reports/implementation-planning-input.template.md +1 -1
- package/runtime/templates/reports/release-handoff-input.template.md +1 -1
- package/runtime/templates/reports/user-response.template.md +1 -1
- package/runtime/templates/worker-prompt-preamble.md +4 -4
- package/runtime/validators/lib/fixtures.sh +2 -2
- package/runtime/validators/validate-implementation-plan-stages.py +9 -9
- package/runtime/validators/validate-report-views.py +10 -10
- package/runtime/validators/validate-run.py +36 -36
- package/runtime/validators/validate_improvement_report.py +8 -8
- package/src/_python-helper.mjs +3 -3
- package/src/context-cost.mjs +27 -0
- package/src/install.mjs +1 -0
- package/src/uninstall.mjs +1 -0
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
"""Build the compact analysis-worker input packet for a task run."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import re
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
BRIEF_SECTIONS = (
|
|
9
|
+
"Identity",
|
|
10
|
+
"Request Summary",
|
|
11
|
+
"Current Context",
|
|
12
|
+
"Evidence and Source Materials",
|
|
13
|
+
"Task-Type Focus",
|
|
14
|
+
"Constraints and Risks",
|
|
15
|
+
"Out of Scope",
|
|
16
|
+
"Configuration References and Expected Values",
|
|
17
|
+
"Deployment Manifests and Expected Values",
|
|
18
|
+
"Questions for Workers",
|
|
19
|
+
"Expected Outputs",
|
|
20
|
+
"Task Continuity Notes",
|
|
21
|
+
"Available MCP Servers",
|
|
22
|
+
)
|
|
23
|
+
PROFILE_SECTIONS = (
|
|
24
|
+
"Primary focus areas",
|
|
25
|
+
"Expected output emphasis",
|
|
26
|
+
"Non-goals",
|
|
27
|
+
"Clarification request policy",
|
|
28
|
+
)
|
|
29
|
+
CLARIFICATION_SECTIONS = (
|
|
30
|
+
"Clarification Items",
|
|
31
|
+
"Clarification Response Carried In From Previous Run",
|
|
32
|
+
)
|
|
33
|
+
_FRONTMATTER_RE = re.compile(r"\A---\n(?P<body>.*?)\n---\n", re.DOTALL)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def build_analysis_packet(
|
|
37
|
+
*,
|
|
38
|
+
task_key: str,
|
|
39
|
+
task_type: str,
|
|
40
|
+
task_brief_path: Path,
|
|
41
|
+
analysis_profile_path: Path,
|
|
42
|
+
reference_expectations_path: Path,
|
|
43
|
+
clarification_response_path: Path | None,
|
|
44
|
+
directive: str,
|
|
45
|
+
instruction_set_relative_path: str,
|
|
46
|
+
) -> str:
|
|
47
|
+
"""Return the primary compact input for Claude/Codex/Gemini analysers."""
|
|
48
|
+
brief_text = task_brief_path.read_text(encoding="utf-8")
|
|
49
|
+
profile_text = analysis_profile_path.read_text(encoding="utf-8")
|
|
50
|
+
reference_text = _read_optional(reference_expectations_path)
|
|
51
|
+
clarification_text = (
|
|
52
|
+
_read_optional(clarification_response_path)
|
|
53
|
+
if clarification_response_path else ""
|
|
54
|
+
)
|
|
55
|
+
parts = [_packet_frontmatter(brief_text, task_key)]
|
|
56
|
+
parts.extend(
|
|
57
|
+
_intro_block(
|
|
58
|
+
task_key,
|
|
59
|
+
instruction_set_relative_path,
|
|
60
|
+
bool(clarification_response_path),
|
|
61
|
+
)
|
|
62
|
+
)
|
|
63
|
+
parts.extend(_brief_block(brief_text))
|
|
64
|
+
parts.extend(_profile_block(task_type, profile_text))
|
|
65
|
+
parts.extend(_reference_block(reference_text))
|
|
66
|
+
parts.extend(_clarification_block(clarification_text))
|
|
67
|
+
parts.extend(_directive_block(directive))
|
|
68
|
+
return "\n".join(part.rstrip() for part in parts).rstrip() + "\n"
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _packet_frontmatter(brief_text: str, task_key: str) -> str:
|
|
72
|
+
frontmatter = _extract_frontmatter(brief_text)
|
|
73
|
+
if frontmatter:
|
|
74
|
+
return (
|
|
75
|
+
f"---\n{frontmatter}\n"
|
|
76
|
+
'packetVersion: "1.0"\n'
|
|
77
|
+
"packetRole: analysis-worker-primary\n---"
|
|
78
|
+
)
|
|
79
|
+
return (
|
|
80
|
+
"---\n"
|
|
81
|
+
f'title: OKSTRA Analysis Packet - {task_key}\n'
|
|
82
|
+
'packetVersion: "1.0"\n'
|
|
83
|
+
"packetRole: analysis-worker-primary\n"
|
|
84
|
+
"---"
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _intro_block(
|
|
89
|
+
task_key: str,
|
|
90
|
+
instruction_set: str,
|
|
91
|
+
has_clarification: bool,
|
|
92
|
+
) -> list[str]:
|
|
93
|
+
lines = [
|
|
94
|
+
f"# OKSTRA Analysis Packet - {task_key}",
|
|
95
|
+
"",
|
|
96
|
+
"## Packet Role",
|
|
97
|
+
"",
|
|
98
|
+
"- This packet is the primary required reading for analysis workers.",
|
|
99
|
+
"- It extracts task-specific material from the source files listed below.",
|
|
100
|
+
"- Read source files only for evidence verification or missing detail.",
|
|
101
|
+
"",
|
|
102
|
+
"## Source Files",
|
|
103
|
+
"",
|
|
104
|
+
f"- Task brief: `{instruction_set}/task-brief.md`",
|
|
105
|
+
f"- Analysis profile: `{instruction_set}/analysis-profile.md`",
|
|
106
|
+
f"- Analysis material: `{instruction_set}/analysis-material.md`",
|
|
107
|
+
f"- Reference expectations: `{instruction_set}/reference-expectations.md`",
|
|
108
|
+
]
|
|
109
|
+
if has_clarification:
|
|
110
|
+
lines.append(
|
|
111
|
+
f"- Clarification response: `{instruction_set}/clarification-response.md`"
|
|
112
|
+
)
|
|
113
|
+
return lines
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _brief_block(brief_text: str) -> list[str]:
|
|
117
|
+
return [
|
|
118
|
+
"",
|
|
119
|
+
"## Task-Specific Brief Extract",
|
|
120
|
+
"",
|
|
121
|
+
_extract_sections(brief_text, BRIEF_SECTIONS),
|
|
122
|
+
]
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _profile_block(task_type: str, profile_text: str) -> list[str]:
|
|
126
|
+
profile_sections = _extract_sections(profile_text, PROFILE_SECTIONS)
|
|
127
|
+
profile_bullets = _extract_bullet_sections(profile_text, PROFILE_SECTIONS)
|
|
128
|
+
profile_focus = "\n\n".join(
|
|
129
|
+
part for part in (profile_sections, profile_bullets)
|
|
130
|
+
if part and not part.startswith("- No matching")
|
|
131
|
+
)
|
|
132
|
+
return [
|
|
133
|
+
"",
|
|
134
|
+
"## Phase Focus Extract",
|
|
135
|
+
"",
|
|
136
|
+
f"- Task Type: `{task_type}`",
|
|
137
|
+
"",
|
|
138
|
+
_extract_profile_prelude(profile_text),
|
|
139
|
+
"",
|
|
140
|
+
profile_focus or "- No matching source sections were available.",
|
|
141
|
+
]
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _reference_block(reference_text: str) -> list[str]:
|
|
145
|
+
body = reference_text.strip() or "- No reference expectations content was available."
|
|
146
|
+
return ["", "## Reference Expectations", "", body]
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _clarification_block(clarification_text: str) -> list[str]:
|
|
150
|
+
if not clarification_text.strip():
|
|
151
|
+
return []
|
|
152
|
+
return [
|
|
153
|
+
"",
|
|
154
|
+
"## Clarification Carry-In Extract",
|
|
155
|
+
"",
|
|
156
|
+
_extract_sections(clarification_text, CLARIFICATION_SECTIONS),
|
|
157
|
+
]
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _directive_block(directive: str) -> list[str]:
|
|
161
|
+
if not directive:
|
|
162
|
+
return []
|
|
163
|
+
return ["", "## Directive", "", directive.strip()]
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _extract_frontmatter(text: str) -> str:
|
|
167
|
+
match = _FRONTMATTER_RE.match(text)
|
|
168
|
+
return match.group("body").rstrip() if match else ""
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _extract_profile_prelude(text: str) -> str:
|
|
172
|
+
lines = []
|
|
173
|
+
for line in text.splitlines():
|
|
174
|
+
if line.startswith(("{{INCLUDE:", "## ", "<!--", "- Team contract")):
|
|
175
|
+
break
|
|
176
|
+
if _top_level_bullet_heading(line) in PROFILE_SECTIONS:
|
|
177
|
+
break
|
|
178
|
+
if line.strip():
|
|
179
|
+
lines.append(line)
|
|
180
|
+
return "\n".join(lines).strip() or "- No profile prelude was available."
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _extract_sections(text: str, headings: tuple[str, ...]) -> str:
|
|
184
|
+
sections = _section_map(text)
|
|
185
|
+
out = []
|
|
186
|
+
for heading in headings:
|
|
187
|
+
body = sections.get(heading, "").strip()
|
|
188
|
+
if body:
|
|
189
|
+
out.append(f"### {heading}\n\n{body}")
|
|
190
|
+
return "\n\n".join(out) or "- No matching source sections were available."
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _extract_bullet_sections(text: str, headings: tuple[str, ...]) -> str:
|
|
194
|
+
heading_set = set(headings)
|
|
195
|
+
captured: list[tuple[str, list[str]]] = []
|
|
196
|
+
current_heading = ""
|
|
197
|
+
current_lines: list[str] = []
|
|
198
|
+
|
|
199
|
+
def flush_current() -> None:
|
|
200
|
+
nonlocal current_heading, current_lines
|
|
201
|
+
if current_heading and current_lines:
|
|
202
|
+
captured.append((current_heading, current_lines))
|
|
203
|
+
current_heading = ""
|
|
204
|
+
current_lines = []
|
|
205
|
+
|
|
206
|
+
for line in _strip_frontmatter(text).splitlines():
|
|
207
|
+
bullet_heading = _top_level_bullet_heading(line)
|
|
208
|
+
if bullet_heading:
|
|
209
|
+
if bullet_heading in heading_set:
|
|
210
|
+
flush_current()
|
|
211
|
+
current_heading = bullet_heading
|
|
212
|
+
current_lines = [line]
|
|
213
|
+
continue
|
|
214
|
+
flush_current()
|
|
215
|
+
continue
|
|
216
|
+
if current_heading:
|
|
217
|
+
if line.startswith(" ") or not line.strip():
|
|
218
|
+
current_lines.append(line)
|
|
219
|
+
else:
|
|
220
|
+
flush_current()
|
|
221
|
+
flush_current()
|
|
222
|
+
|
|
223
|
+
out = []
|
|
224
|
+
for heading, lines in captured:
|
|
225
|
+
body = "\n".join(lines).strip()
|
|
226
|
+
if body:
|
|
227
|
+
out.append(f"### {heading}\n\n{body}")
|
|
228
|
+
return "\n\n".join(out)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _top_level_bullet_heading(line: str) -> str:
|
|
232
|
+
if not line.startswith("- ") or ":" not in line:
|
|
233
|
+
return ""
|
|
234
|
+
label = line[2:].split(":", 1)[0].strip().strip("*")
|
|
235
|
+
return label
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _section_map(text: str) -> dict[str, str]:
|
|
239
|
+
result: dict[str, list[str]] = {}
|
|
240
|
+
current = ""
|
|
241
|
+
for line in _strip_frontmatter(text).splitlines():
|
|
242
|
+
if line.startswith("## "):
|
|
243
|
+
current = line[3:].strip()
|
|
244
|
+
result.setdefault(current, [])
|
|
245
|
+
continue
|
|
246
|
+
if current:
|
|
247
|
+
result[current].append(line)
|
|
248
|
+
return {key: "\n".join(lines).strip() for key, lines in result.items()}
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _strip_frontmatter(text: str) -> str:
|
|
252
|
+
match = _FRONTMATTER_RE.match(text)
|
|
253
|
+
return text[match.end():] if match else text
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _read_optional(path: Path | None) -> str:
|
|
257
|
+
if path and path.is_file():
|
|
258
|
+
return path.read_text(encoding="utf-8")
|
|
259
|
+
return ""
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
"""Parse the ``##
|
|
1
|
+
"""Parse the ``## 1. Clarification Items`` table from a final-report markdown.
|
|
2
2
|
|
|
3
|
-
The unified §
|
|
3
|
+
The unified §1 table (introduced when §4.5.9 / §5.1 / §5.2 collapsed into a
|
|
4
4
|
single section) is the canonical home for every clarification an
|
|
5
5
|
implementation-planning run owes the user — decisions, file attachments,
|
|
6
6
|
single data points. Each row carries a ``Blocks`` column whose value picks
|
|
@@ -12,7 +12,7 @@ This module exposes one read function for that gate so both
|
|
|
12
12
|
``_validate_approved_plan`` (pre-implementation run-prep) and any later
|
|
13
13
|
validator can share the same parsing logic.
|
|
14
14
|
|
|
15
|
-
Legacy compatibility: reports written before the §
|
|
15
|
+
Legacy compatibility: reports written before the §1 unification used
|
|
16
16
|
``4.5.9 Open Questions`` + ``5.1 Additional Materials`` + ``5.2 Questions
|
|
17
17
|
for the User`` and lacked a ``Blocks`` column. Those reports cannot be
|
|
18
18
|
gate-checked by Blocks; the parser returns ``None`` to signal "schema
|
|
@@ -26,13 +26,13 @@ from pathlib import Path
|
|
|
26
26
|
from typing import Optional
|
|
27
27
|
|
|
28
28
|
|
|
29
|
-
SECTION_HEADING_PATTERN = re.compile(r"^##\s+
|
|
30
|
-
NEXT_TOP_LEVEL_HEADING_PATTERN = re.compile(r"^##\s+(?!
|
|
29
|
+
SECTION_HEADING_PATTERN = re.compile(r"^##\s+1\.\s+Clarification Items\s*$", re.MULTILINE)
|
|
30
|
+
NEXT_TOP_LEVEL_HEADING_PATTERN = re.compile(r"^##\s+(?!1\.)", re.MULTILINE)
|
|
31
31
|
|
|
32
32
|
|
|
33
33
|
@dataclass(frozen=True)
|
|
34
34
|
class ClarificationItem:
|
|
35
|
-
"""One row of the §
|
|
35
|
+
"""One row of the §1 table.
|
|
36
36
|
|
|
37
37
|
``raw_*`` fields preserve the exact cell text (after backtick stripping)
|
|
38
38
|
for diagnostics; canonical lowercased versions live in ``blocks`` /
|
|
@@ -77,9 +77,9 @@ def _is_separator_row(line: str) -> bool:
|
|
|
77
77
|
return True
|
|
78
78
|
|
|
79
79
|
|
|
80
|
-
def
|
|
81
|
-
"""Return the substring spanning the §
|
|
82
|
-
next ``##`` heading), or None if §
|
|
80
|
+
def _section_1_slice(report_text: str) -> Optional[str]:
|
|
81
|
+
"""Return the substring spanning the §1 section (heading exclusive of the
|
|
82
|
+
next ``##`` heading), or None if §1 is absent."""
|
|
83
83
|
start_match = SECTION_HEADING_PATTERN.search(report_text)
|
|
84
84
|
if not start_match:
|
|
85
85
|
return None
|
|
@@ -89,7 +89,7 @@ def _section_5_slice(report_text: str) -> Optional[str]:
|
|
|
89
89
|
|
|
90
90
|
|
|
91
91
|
def parse_clarification_items(report_text: str) -> Optional[list[ClarificationItem]]:
|
|
92
|
-
"""Return the list of §
|
|
92
|
+
"""Return the list of §1 rows. ``None`` means "no unified §1 table
|
|
93
93
|
detected" (legacy report or missing section) — caller must NOT treat
|
|
94
94
|
that as "table is empty".
|
|
95
95
|
|
|
@@ -97,7 +97,7 @@ def parse_clarification_items(report_text: str) -> Optional[list[ClarificationIt
|
|
|
97
97
|
just the ``- 추가 정보 요청 없음.`` placeholder); that IS a confident
|
|
98
98
|
"no approval-blocking items".
|
|
99
99
|
"""
|
|
100
|
-
section =
|
|
100
|
+
section = _section_1_slice(report_text)
|
|
101
101
|
if section is None:
|
|
102
102
|
return None
|
|
103
103
|
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
"""Read-side context cost estimator for prepared okstra task bundles.
|
|
2
|
+
|
|
3
|
+
The estimator answers a narrow operational question: how much file/context
|
|
4
|
+
surface does the current task bundle ask the lead, analysis workers, and
|
|
5
|
+
report-writer to absorb? It does not mutate task artifacts.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import argparse
|
|
10
|
+
import json
|
|
11
|
+
import re
|
|
12
|
+
import sys
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Iterable
|
|
15
|
+
|
|
16
|
+
from okstra_project import ResolverError, find_task_root, resolve_project_root
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
INPUT_FILES = (
|
|
20
|
+
"task-brief.md",
|
|
21
|
+
"analysis-profile.md",
|
|
22
|
+
"analysis-material.md",
|
|
23
|
+
"reference-expectations.md",
|
|
24
|
+
"clarification-response.md",
|
|
25
|
+
)
|
|
26
|
+
TIMESTAMPED_ARTIFACT_RE = re.compile(r"\d{4}-\d{2}-\d{2}[_T]\d{2}-\d{2}-\d{2}")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _file_size(path: Path) -> int:
|
|
30
|
+
return path.stat().st_size if path.is_file() else 0
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _count_files(paths: Iterable[Path]) -> tuple[int, int]:
|
|
34
|
+
file_count = 0
|
|
35
|
+
byte_count = 0
|
|
36
|
+
for path in paths:
|
|
37
|
+
if path.is_file():
|
|
38
|
+
file_count += 1
|
|
39
|
+
byte_count += path.stat().st_size
|
|
40
|
+
return file_count, byte_count
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _all_files(root: Path) -> list[Path]:
|
|
44
|
+
if not root.exists():
|
|
45
|
+
return []
|
|
46
|
+
return [path for path in root.rglob("*") if path.is_file()]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _load_json(path: Path) -> dict:
|
|
50
|
+
if not path.is_file():
|
|
51
|
+
return {}
|
|
52
|
+
try:
|
|
53
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
54
|
+
except Exception:
|
|
55
|
+
return {}
|
|
56
|
+
return data if isinstance(data, dict) else {}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _resolve_task_root(target: str, project_root: str, cwd: str) -> tuple[Path, Path]:
|
|
60
|
+
target_path = Path(target).expanduser()
|
|
61
|
+
if target_path.exists():
|
|
62
|
+
task_root = target_path.resolve()
|
|
63
|
+
return task_root, _infer_project_root(task_root)
|
|
64
|
+
|
|
65
|
+
try:
|
|
66
|
+
resolved_project = Path(
|
|
67
|
+
resolve_project_root(explicit_root=project_root, cwd=cwd)
|
|
68
|
+
).resolve()
|
|
69
|
+
except ResolverError as exc:
|
|
70
|
+
raise SystemExit(f"project root resolution failed: {exc}") from exc
|
|
71
|
+
|
|
72
|
+
task_root = find_task_root(resolved_project, target)
|
|
73
|
+
if task_root is None:
|
|
74
|
+
raise SystemExit(f"no task root for task key: {target}")
|
|
75
|
+
return task_root.resolve(), resolved_project
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _infer_project_root(task_root: Path) -> Path:
|
|
79
|
+
parts = task_root.parts
|
|
80
|
+
if ".okstra" not in parts:
|
|
81
|
+
return task_root
|
|
82
|
+
idx = parts.index(".okstra")
|
|
83
|
+
return Path(*parts[:idx]).resolve()
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _project_rel(path: Path, project_root: Path) -> str:
|
|
87
|
+
try:
|
|
88
|
+
return str(path.resolve().relative_to(project_root.resolve()))
|
|
89
|
+
except ValueError:
|
|
90
|
+
return str(path)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _find_current_run_dir(
|
|
94
|
+
task_root: Path, manifest: dict, project_root: Path
|
|
95
|
+
) -> Path | None:
|
|
96
|
+
artifacts = manifest.get("artifacts", {})
|
|
97
|
+
prompt_dir = artifacts.get("workerPromptsDirectoryPath")
|
|
98
|
+
if isinstance(prompt_dir, str) and prompt_dir:
|
|
99
|
+
path = project_root / prompt_dir
|
|
100
|
+
if path.is_dir():
|
|
101
|
+
return path.parent
|
|
102
|
+
|
|
103
|
+
task_type = manifest.get("workflow", {}).get("currentPhase") or manifest.get("taskType")
|
|
104
|
+
if not isinstance(task_type, str) or not task_type:
|
|
105
|
+
return None
|
|
106
|
+
candidate = task_root / "runs" / task_type
|
|
107
|
+
return candidate if candidate.is_dir() else None
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _latest_matching_file(directory: Path, pattern: str) -> Path | None:
|
|
111
|
+
matches = sorted(directory.glob(pattern))
|
|
112
|
+
if not matches:
|
|
113
|
+
return None
|
|
114
|
+
return max(matches, key=lambda path: path.stat().st_mtime)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _is_timestamped_legacy_artifact(path: Path) -> bool:
|
|
118
|
+
return bool(TIMESTAMPED_ARTIFACT_RE.search(path.name))
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _instruction_set_metric(task_root: Path, project_root: Path) -> dict:
|
|
122
|
+
instruction_set = task_root / "instruction-set"
|
|
123
|
+
files = _all_files(instruction_set)
|
|
124
|
+
file_count, byte_count = _count_files(files)
|
|
125
|
+
packet = instruction_set / "analysis-packet.md"
|
|
126
|
+
legacy_packet = instruction_set / "task-packet.md"
|
|
127
|
+
return {
|
|
128
|
+
"path": _project_rel(instruction_set, project_root),
|
|
129
|
+
"fileCount": file_count,
|
|
130
|
+
"bytes": byte_count,
|
|
131
|
+
"analysisPacketBytes": _file_size(packet),
|
|
132
|
+
"legacyTaskPacketBytes": _file_size(legacy_packet),
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _lead_phase1_metric(
|
|
137
|
+
task_root: Path, run_dir: Path | None, manifest: dict, project_root: Path
|
|
138
|
+
) -> dict:
|
|
139
|
+
active_path = None
|
|
140
|
+
artifacts = manifest.get("artifacts", {})
|
|
141
|
+
active_rel = artifacts.get("activeRunContextPath")
|
|
142
|
+
if isinstance(active_rel, str) and active_rel:
|
|
143
|
+
active_path = project_root / active_rel
|
|
144
|
+
|
|
145
|
+
if active_path and active_path.is_file():
|
|
146
|
+
instruction_set = task_root / "instruction-set"
|
|
147
|
+
packet = instruction_set / "analysis-packet.md"
|
|
148
|
+
files = [
|
|
149
|
+
task_root / "task-manifest.json",
|
|
150
|
+
active_path,
|
|
151
|
+
instruction_set / "analysis-profile.md",
|
|
152
|
+
packet if packet.is_file() else instruction_set / "task-brief.md",
|
|
153
|
+
]
|
|
154
|
+
mode = "active-run-context"
|
|
155
|
+
else:
|
|
156
|
+
current_phase = manifest.get("workflow", {}).get("currentPhase") or manifest.get("taskType", "")
|
|
157
|
+
run_manifest = None
|
|
158
|
+
team_state = None
|
|
159
|
+
if run_dir is not None:
|
|
160
|
+
manifests_dir = run_dir / "manifests"
|
|
161
|
+
state_dir = run_dir / "state"
|
|
162
|
+
run_manifest = _latest_matching_file(
|
|
163
|
+
manifests_dir, f"run-manifest-{current_phase}-*.json"
|
|
164
|
+
)
|
|
165
|
+
team_state = _latest_matching_file(
|
|
166
|
+
state_dir, f"team-state-{current_phase}-*.json"
|
|
167
|
+
)
|
|
168
|
+
files = [
|
|
169
|
+
task_root / "task-manifest.json",
|
|
170
|
+
task_root / "instruction-set" / "task-brief.md",
|
|
171
|
+
task_root / "instruction-set" / "analysis-profile.md",
|
|
172
|
+
]
|
|
173
|
+
if run_manifest:
|
|
174
|
+
files.append(run_manifest)
|
|
175
|
+
if team_state:
|
|
176
|
+
files.append(team_state)
|
|
177
|
+
mode = "legacy-five-file"
|
|
178
|
+
|
|
179
|
+
file_count, byte_count = _count_files(files)
|
|
180
|
+
return {
|
|
181
|
+
"mode": mode,
|
|
182
|
+
"fileCount": file_count,
|
|
183
|
+
"bytes": byte_count,
|
|
184
|
+
"files": [_project_rel(path, project_root) for path in files if path.is_file()],
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _analysis_worker_metric(task_root: Path, project_root: Path) -> dict:
|
|
189
|
+
instruction_set = task_root / "instruction-set"
|
|
190
|
+
full_contract_files = [instruction_set / name for name in INPUT_FILES]
|
|
191
|
+
preamble = Path.home() / ".okstra" / "templates" / "worker-prompt-preamble.md"
|
|
192
|
+
if not preamble.is_file():
|
|
193
|
+
repo_preamble = (
|
|
194
|
+
Path(__file__).resolve().parents[2]
|
|
195
|
+
/ "templates"
|
|
196
|
+
/ "worker-prompt-preamble.md"
|
|
197
|
+
)
|
|
198
|
+
preamble = repo_preamble
|
|
199
|
+
full_contract_files.append(preamble)
|
|
200
|
+
full_file_count, full_byte_count = _count_files(full_contract_files)
|
|
201
|
+
|
|
202
|
+
packet = instruction_set / "analysis-packet.md"
|
|
203
|
+
legacy_packet = instruction_set / "task-packet.md"
|
|
204
|
+
primary_packet = packet if packet.is_file() else legacy_packet
|
|
205
|
+
has_packet = primary_packet.is_file()
|
|
206
|
+
packet_files = [primary_packet, preamble] if has_packet else []
|
|
207
|
+
packet_file_count, packet_byte_count = _count_files(packet_files)
|
|
208
|
+
mode = "analysis-packet-primary" if packet.is_file() else "full-input-contract"
|
|
209
|
+
byte_count = packet_byte_count if packet.is_file() else full_byte_count
|
|
210
|
+
file_count = packet_file_count if packet.is_file() else full_file_count
|
|
211
|
+
current_files = packet_files if packet.is_file() else full_contract_files
|
|
212
|
+
reduction_percent = 0
|
|
213
|
+
if full_byte_count and has_packet:
|
|
214
|
+
reduction_percent = round((1 - packet_byte_count / full_byte_count) * 100)
|
|
215
|
+
|
|
216
|
+
return {
|
|
217
|
+
"mode": mode,
|
|
218
|
+
"fileCount": file_count,
|
|
219
|
+
"bytesPerWorker": byte_count,
|
|
220
|
+
"legacyFullContractBytesPerWorker": full_byte_count,
|
|
221
|
+
"legacyFullContractFileCount": full_file_count,
|
|
222
|
+
"estimatedPacketModeBytesPerWorker": packet_byte_count,
|
|
223
|
+
"estimatedReductionPercent": reduction_percent,
|
|
224
|
+
"files": [_project_rel(path, project_root) for path in current_files if path.is_file()],
|
|
225
|
+
"legacyFullContractFiles": [
|
|
226
|
+
_project_rel(path, project_root)
|
|
227
|
+
for path in full_contract_files
|
|
228
|
+
if path.is_file()
|
|
229
|
+
],
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _report_writer_metric(run_dir: Path | None, task_root: Path, project_root: Path) -> dict:
|
|
234
|
+
instruction_set = task_root / "instruction-set"
|
|
235
|
+
files = [instruction_set / name for name in INPUT_FILES]
|
|
236
|
+
files.extend([
|
|
237
|
+
instruction_set / "final-report-template.md",
|
|
238
|
+
instruction_set / "final-report-schema.json",
|
|
239
|
+
])
|
|
240
|
+
if run_dir is not None:
|
|
241
|
+
worker_results = run_dir / "worker-results"
|
|
242
|
+
files.extend(
|
|
243
|
+
path for path in worker_results.glob("*.md")
|
|
244
|
+
if "-audit-" not in path.name and "report-writer" not in path.name
|
|
245
|
+
)
|
|
246
|
+
convergence = _latest_matching_file(
|
|
247
|
+
run_dir / "state", f"convergence-{run_dir.name}-*.json"
|
|
248
|
+
)
|
|
249
|
+
if convergence:
|
|
250
|
+
files.append(convergence)
|
|
251
|
+
file_count, byte_count = _count_files(files)
|
|
252
|
+
return {
|
|
253
|
+
"mode": "raw-synthesis-inputs",
|
|
254
|
+
"fileCount": file_count,
|
|
255
|
+
"bytes": byte_count,
|
|
256
|
+
"files": [_project_rel(path, project_root) for path in files if path.is_file()],
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def analyze_task_bundle(task_root: Path, project_root: Path) -> dict:
|
|
261
|
+
manifest = _load_json(task_root / "task-manifest.json")
|
|
262
|
+
run_dir = _find_current_run_dir(task_root, manifest, project_root)
|
|
263
|
+
all_task_files = _all_files(task_root)
|
|
264
|
+
task_file_count, task_bytes = _count_files(all_task_files)
|
|
265
|
+
current_run_file_count, current_run_bytes = _count_files(_all_files(run_dir)) if run_dir else (0, 0)
|
|
266
|
+
legacy_timestamp_files = [
|
|
267
|
+
path for path in (task_root / "runs").rglob("*")
|
|
268
|
+
if path.is_file() and _is_timestamped_legacy_artifact(path)
|
|
269
|
+
]
|
|
270
|
+
|
|
271
|
+
return {
|
|
272
|
+
"ok": True,
|
|
273
|
+
"taskRoot": _project_rel(task_root, project_root),
|
|
274
|
+
"projectRoot": str(project_root),
|
|
275
|
+
"taskKey": manifest.get("taskKey", ""),
|
|
276
|
+
"taskType": manifest.get("taskType", ""),
|
|
277
|
+
"currentRunPath": _project_rel(run_dir, project_root) if run_dir else "",
|
|
278
|
+
"totals": {
|
|
279
|
+
"taskFileCount": task_file_count,
|
|
280
|
+
"taskBytes": task_bytes,
|
|
281
|
+
"currentRunFileCount": current_run_file_count,
|
|
282
|
+
"currentRunBytes": current_run_bytes,
|
|
283
|
+
"legacyTimestampFileCount": len(legacy_timestamp_files),
|
|
284
|
+
},
|
|
285
|
+
"instructionSet": _instruction_set_metric(task_root, project_root),
|
|
286
|
+
"leadPhase1": _lead_phase1_metric(task_root, run_dir, manifest, project_root),
|
|
287
|
+
"analysisWorker": _analysis_worker_metric(task_root, project_root),
|
|
288
|
+
"reportWriter": _report_writer_metric(run_dir, task_root, project_root),
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def main(argv: list[str] | None = None) -> int:
|
|
293
|
+
parser = argparse.ArgumentParser(
|
|
294
|
+
description="Estimate context/file-read cost for an okstra task bundle."
|
|
295
|
+
)
|
|
296
|
+
parser.add_argument("target", help="task root path or task-key")
|
|
297
|
+
parser.add_argument("--project-root", default="", help="project root for task-key lookup")
|
|
298
|
+
parser.add_argument("--cwd", default=".", help="cwd for project root resolution")
|
|
299
|
+
args = parser.parse_args(argv)
|
|
300
|
+
|
|
301
|
+
task_root, project_root = _resolve_task_root(args.target, args.project_root, args.cwd)
|
|
302
|
+
result = analyze_task_bundle(task_root, project_root)
|
|
303
|
+
print(json.dumps(result, ensure_ascii=False, indent=2))
|
|
304
|
+
return 0
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
if __name__ == "__main__":
|
|
308
|
+
raise SystemExit(main(sys.argv[1:]))
|
|
@@ -23,6 +23,7 @@ from dataclasses import dataclass, field
|
|
|
23
23
|
from pathlib import Path
|
|
24
24
|
from typing import Optional
|
|
25
25
|
|
|
26
|
+
from okstra_ctl.worktree import is_git_work_tree
|
|
26
27
|
from okstra_project.dirs import (
|
|
27
28
|
LEGACY_OKSTRA_DIR_NAME,
|
|
28
29
|
LEGACY_OKSTRA_RELATIVE,
|
|
@@ -112,7 +113,7 @@ def prepare_migration_plan(
|
|
|
112
113
|
f"Remove it manually if you intend to overwrite, then re-run."
|
|
113
114
|
)
|
|
114
115
|
|
|
115
|
-
use_git =
|
|
116
|
+
use_git = is_git_work_tree(project_root)
|
|
116
117
|
parent = source.parent # <project>/.project-docs
|
|
117
118
|
remove_empty_parent = _would_be_empty_after_remove(parent, source)
|
|
118
119
|
|
|
@@ -194,17 +195,6 @@ def _resolve_okstra_home(override: Optional[Path]) -> Path:
|
|
|
194
195
|
return Path.home() / ".okstra"
|
|
195
196
|
|
|
196
197
|
|
|
197
|
-
def _is_git_worktree(project_root: Path) -> bool:
|
|
198
|
-
try:
|
|
199
|
-
rc = subprocess.run(
|
|
200
|
-
["git", "rev-parse", "--show-toplevel"],
|
|
201
|
-
cwd=str(project_root), capture_output=True, text=True, check=False,
|
|
202
|
-
)
|
|
203
|
-
except (OSError, FileNotFoundError):
|
|
204
|
-
return False
|
|
205
|
-
return rc.returncode == 0 and bool(rc.stdout.strip())
|
|
206
|
-
|
|
207
|
-
|
|
208
198
|
def _would_be_empty_after_remove(parent: Path, child: Path) -> bool:
|
|
209
199
|
"""Return True if `parent` would be empty after `child` is moved out."""
|
|
210
200
|
if not parent.is_dir():
|