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