okstra 0.50.0 → 0.52.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 +15 -16
- package/docs/kr/cli.md +5 -5
- package/docs/project-structure-overview.md +10 -6
- package/docs/superpowers/specs/2026-06-06-vertical-slice-tdd-planning-design.md +179 -0
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/agents/SKILL.md +15 -11
- package/runtime/agents/workers/claude-worker.md +3 -3
- package/runtime/agents/workers/codex-worker.md +2 -2
- package/runtime/agents/workers/gemini-worker.md +2 -2
- 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-team-reconcile.sh +28 -0
- package/runtime/bin/okstra.sh +2 -0
- package/runtime/prompts/launch.template.md +3 -1
- package/runtime/prompts/profiles/_common-contract.md +4 -4
- package/runtime/prompts/profiles/_implementation-executor.md +2 -2
- package/runtime/prompts/profiles/_implementation-verifier.md +1 -1
- package/runtime/prompts/profiles/implementation-planning.md +8 -4
- package/runtime/prompts/profiles/implementation.md +1 -1
- package/runtime/python/okstra_ctl/analysis_packet.py +259 -0
- 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 +284 -125
- package/runtime/python/okstra_ctl/render_final_report.py +31 -0
- package/runtime/python/okstra_ctl/run.py +507 -245
- 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 +129 -133
- package/runtime/python/okstra_ctl/worktree.py +13 -5
- package/runtime/schemas/final-report-v1.0.schema.json +4 -0
- 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-inspect/SKILL.md +100 -1
- package/runtime/skills/okstra-memory/SKILL.md +28 -5
- package/runtime/skills/okstra-report-writer/SKILL.md +5 -1
- package/runtime/skills/okstra-run/SKILL.md +1 -1
- package/runtime/skills/okstra-team-contract/SKILL.md +7 -4
- package/runtime/templates/reports/final-report.template.md +1 -0
- package/runtime/templates/worker-prompt-preamble.md +3 -3
- package/runtime/validators/validate-implementation-plan-stages.py +57 -11
- package/src/_python-helper.mjs +3 -3
- package/src/context-cost.mjs +27 -0
- package/src/install.mjs +1 -0
- package/src/memory.mjs +50 -11
- 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 ""
|
|
@@ -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():
|
|
@@ -31,9 +31,25 @@ __all__ = [
|
|
|
31
31
|
"DISCOVERY_RELATIVE",
|
|
32
32
|
"compute_run_paths",
|
|
33
33
|
"next_run_seq",
|
|
34
|
+
"task_dir",
|
|
35
|
+
"task_runs_dir",
|
|
34
36
|
]
|
|
35
37
|
|
|
36
38
|
|
|
39
|
+
def task_dir(project_root: Path, task_group: str, task_id: str) -> Path:
|
|
40
|
+
"""task root 경로: ``<project>/.okstra/tasks/<group-seg>/<id-seg>``.
|
|
41
|
+
|
|
42
|
+
raw group/id 를 받아 내부에서 slugify 한다 — compute_run_paths 가 쓰는 것과
|
|
43
|
+
동일한 segment 규칙이다. 과거 sequence/wizard 가 이 구조를 손으로 재유도해
|
|
44
|
+
silent drift 위험이 있던 것을 이 SSOT 로 모은다."""
|
|
45
|
+
return Path(project_root) / TASKS_RELATIVE / slugify(task_group) / slugify(task_id)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def task_runs_dir(project_root: Path, task_group: str, task_id: str) -> Path:
|
|
49
|
+
"""task 의 runs 디렉터리: ``task_dir/runs``."""
|
|
50
|
+
return task_dir(project_root, task_group, task_id) / "runs"
|
|
51
|
+
|
|
52
|
+
|
|
37
53
|
def next_run_seq(run_seq_dir: Path, task_type_segment: str) -> int:
|
|
38
54
|
"""run_seq_dir 안에서 `*-<task-type>-NNN.<ext>` 파일을 스캔해 다음 seq 번호를
|
|
39
55
|
돌려준다. 디렉터리 부재 시 1.
|
|
@@ -100,6 +116,7 @@ def compute_run_paths(
|
|
|
100
116
|
task_manifest = task_root / "task-manifest.json"
|
|
101
117
|
task_index = task_root / "task-index.md"
|
|
102
118
|
instruction_set = task_root / "instruction-set"
|
|
119
|
+
analysis_packet = instruction_set / "analysis-packet.md"
|
|
103
120
|
runs_dir = task_root / "runs"
|
|
104
121
|
history_dir = task_root / "history"
|
|
105
122
|
timeline_file = history_dir / "timeline.json"
|
|
@@ -143,6 +160,7 @@ def compute_run_paths(
|
|
|
143
160
|
final_report = run_reports / f"final-report{suffixes['reports']}.md"
|
|
144
161
|
final_status = run_status / f"final{suffixes['status']}.status"
|
|
145
162
|
team_state = run_state / f"team-state{suffixes['state']}.json"
|
|
163
|
+
active_run_context = run_state / f"active-run-context{suffixes['state']}.json"
|
|
146
164
|
final_report_template = instruction_set / "final-report-template.md"
|
|
147
165
|
final_report_schema = instruction_set / "final-report-schema.json"
|
|
148
166
|
reference_expectations = instruction_set / "reference-expectations.md"
|
|
@@ -183,6 +201,7 @@ def compute_run_paths(
|
|
|
183
201
|
"TASK_MANIFEST_PATH": str(task_manifest),
|
|
184
202
|
"TASK_INDEX_PATH": str(task_index),
|
|
185
203
|
"INSTRUCTION_SET_PATH": str(instruction_set),
|
|
204
|
+
"ANALYSIS_PACKET_PATH": str(analysis_packet),
|
|
186
205
|
"RUNS_DIR": str(runs_dir),
|
|
187
206
|
"HISTORY_DIR": str(history_dir),
|
|
188
207
|
"TIMELINE_PATH": str(timeline_file),
|
|
@@ -205,6 +224,7 @@ def compute_run_paths(
|
|
|
205
224
|
"FINAL_REPORT_PATH": str(final_report),
|
|
206
225
|
"FINAL_STATUS_PATH": str(final_status),
|
|
207
226
|
"TEAM_STATE_PATH": str(team_state),
|
|
227
|
+
"ACTIVE_RUN_CONTEXT_PATH": str(active_run_context),
|
|
208
228
|
"FINAL_REPORT_TEMPLATE_PATH": str(final_report_template),
|
|
209
229
|
"FINAL_REPORT_SCHEMA_PATH": str(final_report_schema),
|
|
210
230
|
"REFERENCE_EXPECTATIONS_FILE": str(reference_expectations),
|
|
@@ -244,6 +264,7 @@ def compute_run_paths(
|
|
|
244
264
|
("TASK_MANIFEST_RELATIVE_PATH", task_manifest),
|
|
245
265
|
("TASK_INDEX_RELATIVE_PATH", task_index),
|
|
246
266
|
("INSTRUCTION_SET_RELATIVE_PATH", instruction_set),
|
|
267
|
+
("ANALYSIS_PACKET_RELATIVE_PATH", analysis_packet),
|
|
247
268
|
("RUNS_RELATIVE_PATH", runs_dir),
|
|
248
269
|
("HISTORY_RELATIVE_PATH", history_dir),
|
|
249
270
|
("TIMELINE_RELATIVE_PATH", timeline_file),
|
|
@@ -263,6 +284,7 @@ def compute_run_paths(
|
|
|
263
284
|
("FINAL_REPORT_RELATIVE_PATH", final_report),
|
|
264
285
|
("FINAL_STATUS_RELATIVE_PATH", final_status),
|
|
265
286
|
("TEAM_STATE_RELATIVE_PATH", team_state),
|
|
287
|
+
("ACTIVE_RUN_CONTEXT_RELATIVE_PATH", active_run_context),
|
|
266
288
|
("WORKER_RESULTS_RELATIVE_PATH", worker_results),
|
|
267
289
|
("RUN_CARRY_RELATIVE_PATH", run_carry),
|
|
268
290
|
("FINAL_REPORT_TEMPLATE_RELATIVE_PATH", final_report_template),
|