okstra 0.50.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.
Files changed (57) hide show
  1. package/README.kr.md +8 -7
  2. package/README.md +8 -7
  3. package/bin/okstra +2 -0
  4. package/docs/kr/architecture.md +15 -16
  5. package/docs/kr/cli.md +5 -5
  6. package/docs/project-structure-overview.md +10 -6
  7. package/package.json +1 -1
  8. package/runtime/BUILD.json +2 -2
  9. package/runtime/agents/SKILL.md +15 -11
  10. package/runtime/agents/workers/claude-worker.md +3 -3
  11. package/runtime/agents/workers/codex-worker.md +2 -2
  12. package/runtime/agents/workers/gemini-worker.md +2 -2
  13. package/runtime/bin/lib/okstra/cli.sh +8 -1
  14. package/runtime/bin/lib/okstra/globals.sh +3 -0
  15. package/runtime/bin/lib/okstra/interactive.sh +14 -12
  16. package/runtime/bin/lib/okstra/usage.sh +6 -0
  17. package/runtime/bin/okstra-team-reconcile.sh +28 -0
  18. package/runtime/bin/okstra.sh +2 -0
  19. package/runtime/prompts/launch.template.md +3 -1
  20. package/runtime/prompts/profiles/_common-contract.md +4 -4
  21. package/runtime/prompts/profiles/_implementation-executor.md +2 -2
  22. package/runtime/prompts/profiles/_implementation-verifier.md +1 -1
  23. package/runtime/prompts/profiles/implementation-planning.md +1 -0
  24. package/runtime/prompts/profiles/implementation.md +1 -1
  25. package/runtime/python/okstra_ctl/analysis_packet.py +259 -0
  26. package/runtime/python/okstra_ctl/context_cost.py +308 -0
  27. package/runtime/python/okstra_ctl/migrate.py +2 -12
  28. package/runtime/python/okstra_ctl/paths.py +22 -0
  29. package/runtime/python/okstra_ctl/render.py +284 -125
  30. package/runtime/python/okstra_ctl/render_final_report.py +31 -0
  31. package/runtime/python/okstra_ctl/run.py +507 -245
  32. package/runtime/python/okstra_ctl/sequence.py +2 -5
  33. package/runtime/python/okstra_ctl/team_reconcile.py +131 -0
  34. package/runtime/python/okstra_ctl/wizard.py +129 -133
  35. package/runtime/python/okstra_ctl/worktree.py +13 -5
  36. package/runtime/schemas/final-report-v1.0.schema.json +4 -0
  37. package/runtime/skills/okstra-coding-preflight/SKILL.md +69 -0
  38. package/runtime/skills/okstra-coding-preflight/architecture/hexagonal.md +116 -0
  39. package/runtime/skills/okstra-coding-preflight/clean-code.md +254 -0
  40. package/runtime/skills/okstra-coding-preflight/languages/java.md +64 -0
  41. package/runtime/skills/okstra-coding-preflight/languages/javascript-typescript.md +87 -0
  42. package/runtime/skills/okstra-coding-preflight/languages/kotlin.md +69 -0
  43. package/runtime/skills/okstra-coding-preflight/languages/nodejs.md +66 -0
  44. package/runtime/skills/okstra-coding-preflight/languages/python.md +179 -0
  45. package/runtime/skills/okstra-coding-preflight/languages/rust.md +105 -0
  46. package/runtime/skills/okstra-coding-preflight/languages/sql.md +68 -0
  47. package/runtime/skills/okstra-context-loader/SKILL.md +12 -6
  48. package/runtime/skills/okstra-inspect/SKILL.md +100 -1
  49. package/runtime/skills/okstra-report-writer/SKILL.md +5 -1
  50. package/runtime/skills/okstra-run/SKILL.md +1 -1
  51. package/runtime/skills/okstra-team-contract/SKILL.md +7 -4
  52. package/runtime/templates/reports/final-report.template.md +1 -0
  53. package/runtime/templates/worker-prompt-preamble.md +3 -3
  54. package/src/_python-helper.mjs +3 -3
  55. package/src/context-cost.mjs +27 -0
  56. package/src/install.mjs +1 -0
  57. package/src/uninstall.mjs +1 -0
@@ -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 = _is_git_worktree(project_root)
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),