okstra 0.1.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 (106) hide show
  1. package/README.md +36 -0
  2. package/bin/okstra +62 -0
  3. package/package.json +30 -0
  4. package/runtime/.gitkeep +0 -0
  5. package/runtime/BUILD.json +5 -0
  6. package/runtime/agents/SKILL.md +243 -0
  7. package/runtime/agents/TODO.md +168 -0
  8. package/runtime/agents/workers/claude-worker.md +106 -0
  9. package/runtime/agents/workers/codex-worker.md +179 -0
  10. package/runtime/agents/workers/gemini-worker.md +179 -0
  11. package/runtime/agents/workers/report-writer-worker.md +116 -0
  12. package/runtime/bin/okstra-central.sh +152 -0
  13. package/runtime/bin/okstra-codex-exec.sh +53 -0
  14. package/runtime/bin/okstra-error-log.py +295 -0
  15. package/runtime/bin/okstra-gemini-exec.sh +55 -0
  16. package/runtime/bin/okstra-token-usage.py +46 -0
  17. package/runtime/bin/okstra.sh +162 -0
  18. package/runtime/prompts/launch.template.md +52 -0
  19. package/runtime/prompts/profiles/error-analysis.md +43 -0
  20. package/runtime/prompts/profiles/final-verification.md +37 -0
  21. package/runtime/prompts/profiles/implementation-planning.md +85 -0
  22. package/runtime/prompts/profiles/implementation.md +71 -0
  23. package/runtime/prompts/profiles/requirements-discovery.md +43 -0
  24. package/runtime/python/lib/okstra/cli.sh +227 -0
  25. package/runtime/python/lib/okstra/globals.sh +157 -0
  26. package/runtime/python/lib/okstra/interactive.sh +411 -0
  27. package/runtime/python/lib/okstra/project-resolver.sh +57 -0
  28. package/runtime/python/lib/okstra/usage.sh +98 -0
  29. package/runtime/python/lib/okstra-ctl/cmd-batch.sh +59 -0
  30. package/runtime/python/lib/okstra-ctl/cmd-list.sh +35 -0
  31. package/runtime/python/lib/okstra-ctl/cmd-open.sh +36 -0
  32. package/runtime/python/lib/okstra-ctl/cmd-projects.sh +26 -0
  33. package/runtime/python/lib/okstra-ctl/cmd-reconcile.sh +27 -0
  34. package/runtime/python/lib/okstra-ctl/cmd-reindex.sh +38 -0
  35. package/runtime/python/lib/okstra-ctl/cmd-rerun.sh +326 -0
  36. package/runtime/python/lib/okstra-ctl/cmd-show.sh +27 -0
  37. package/runtime/python/lib/okstra-ctl/cmd-tail.sh +76 -0
  38. package/runtime/python/lib/okstra-ctl/main.sh +41 -0
  39. package/runtime/python/lib/okstra-ctl/prepare.sh +29 -0
  40. package/runtime/python/lib/okstra-ctl/usage.sh +23 -0
  41. package/runtime/python/okstra_ctl/__init__.py +125 -0
  42. package/runtime/python/okstra_ctl/backfill.py +253 -0
  43. package/runtime/python/okstra_ctl/batch.py +62 -0
  44. package/runtime/python/okstra_ctl/ids.py +84 -0
  45. package/runtime/python/okstra_ctl/index.py +216 -0
  46. package/runtime/python/okstra_ctl/invocation.py +49 -0
  47. package/runtime/python/okstra_ctl/jsonl.py +84 -0
  48. package/runtime/python/okstra_ctl/listing.py +156 -0
  49. package/runtime/python/okstra_ctl/locks.py +42 -0
  50. package/runtime/python/okstra_ctl/material.py +62 -0
  51. package/runtime/python/okstra_ctl/models.py +63 -0
  52. package/runtime/python/okstra_ctl/path_resolve.py +40 -0
  53. package/runtime/python/okstra_ctl/paths.py +251 -0
  54. package/runtime/python/okstra_ctl/project_meta.py +51 -0
  55. package/runtime/python/okstra_ctl/reconcile.py +166 -0
  56. package/runtime/python/okstra_ctl/render.py +1065 -0
  57. package/runtime/python/okstra_ctl/resolver.py +54 -0
  58. package/runtime/python/okstra_ctl/run.py +674 -0
  59. package/runtime/python/okstra_ctl/run_context.py +166 -0
  60. package/runtime/python/okstra_ctl/seeding.py +97 -0
  61. package/runtime/python/okstra_ctl/sequence.py +53 -0
  62. package/runtime/python/okstra_ctl/session.py +33 -0
  63. package/runtime/python/okstra_ctl/tmux.py +27 -0
  64. package/runtime/python/okstra_ctl/workers.py +64 -0
  65. package/runtime/python/okstra_ctl/workflow.py +182 -0
  66. package/runtime/python/okstra_project/__init__.py +41 -0
  67. package/runtime/python/okstra_project/resolver.py +126 -0
  68. package/runtime/python/okstra_project/state.py +170 -0
  69. package/runtime/python/okstra_token_usage/__init__.py +26 -0
  70. package/runtime/python/okstra_token_usage/blocks.py +62 -0
  71. package/runtime/python/okstra_token_usage/claude.py +97 -0
  72. package/runtime/python/okstra_token_usage/cli.py +84 -0
  73. package/runtime/python/okstra_token_usage/codex.py +80 -0
  74. package/runtime/python/okstra_token_usage/collect.py +161 -0
  75. package/runtime/python/okstra_token_usage/gemini.py +77 -0
  76. package/runtime/python/okstra_token_usage/jsonl_io.py +18 -0
  77. package/runtime/python/okstra_token_usage/paths.py +22 -0
  78. package/runtime/python/okstra_token_usage/pricing.py +71 -0
  79. package/runtime/python/okstra_token_usage/report.py +64 -0
  80. package/runtime/templates/prd/brief.template.md +273 -0
  81. package/runtime/templates/project-docs/task-index.template.md +65 -0
  82. package/runtime/templates/reports/error-analysis-input.template.md +80 -0
  83. package/runtime/templates/reports/final-report.template.md +167 -0
  84. package/runtime/templates/reports/final-verification-input.template.md +67 -0
  85. package/runtime/templates/reports/implementation-input.template.md +81 -0
  86. package/runtime/templates/reports/implementation-planning-input.template.md +93 -0
  87. package/runtime/templates/reports/quick-input.template.md +64 -0
  88. package/runtime/templates/reports/schedule.template.md +168 -0
  89. package/runtime/templates/reports/settings.template.json +101 -0
  90. package/runtime/templates/reports/task-brief.template.md +165 -0
  91. package/runtime/validators/lib/common.sh +44 -0
  92. package/runtime/validators/lib/fixtures.sh +322 -0
  93. package/runtime/validators/lib/paths.sh +44 -0
  94. package/runtime/validators/lib/runners.sh +140 -0
  95. package/runtime/validators/lib/summary.sh +15 -0
  96. package/runtime/validators/lib/validate-assets.sh +44 -0
  97. package/runtime/validators/lib/validate-prompt-metadata.sh +267 -0
  98. package/runtime/validators/lib/validate-tasks.sh +335 -0
  99. package/runtime/validators/validate-run.py +568 -0
  100. package/runtime/validators/validate-schedule.py +665 -0
  101. package/runtime/validators/validate-workflow.sh +190 -0
  102. package/src/doctor.mjs +127 -0
  103. package/src/install.mjs +355 -0
  104. package/src/paths.mjs +132 -0
  105. package/src/uninstall.mjs +122 -0
  106. package/src/version.mjs +20 -0
@@ -0,0 +1,161 @@
1
+ """Top-level orchestrator that walks team-state and gathers all worker usage."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ from pathlib import Path
6
+ from .blocks import na_block, usage_block
7
+ from .claude import claude_session_totals, find_claude_team_sessions
8
+ from .codex import codex_session_total, find_codex_session
9
+ from .gemini import find_gemini_session, gemini_session_total
10
+ from .paths import claude_project_dir, utc_now
11
+ from .pricing import codex_cost_usd, gemini_cost_usd
12
+
13
+
14
+ def collect(team_state_path: Path, project_root: Path | None = None) -> dict:
15
+ state = json.loads(team_state_path.read_text())
16
+ cwd = project_root or _infer_project_root(team_state_path, state)
17
+ task_key = state.get("taskKey", "")
18
+ # Prefer the team name actually persisted in team-state (set during Phase 3
19
+ # when TeamCreate succeeded); only fall back to the `okstra-<task-id>`
20
+ # convention if team-state did not record one. Matching downstream is
21
+ # case-insensitive so either casing works.
22
+ state_team = (state.get("team") or {})
23
+ team_name = state_team.get("teamName") or ""
24
+ if not team_name:
25
+ task_id = task_key.rsplit(":", 1)[-1] if task_key else ""
26
+ team_name = f"okstra-{task_id}" if task_id else ""
27
+ lead_sid = (state.get("lead") or {}).get("sessionId")
28
+
29
+ # 1) Claude sessions (lead + claude-side workers).
30
+ claude_sessions = find_claude_team_sessions(cwd, team_name, lead_sid)
31
+ by_agent: dict[str, tuple[str, Path]] = {}
32
+ lead_path: Path | None = None
33
+ for sid, path in claude_sessions.items():
34
+ if sid == lead_sid:
35
+ lead_path = path
36
+ continue
37
+ # Read agentName lazily.
38
+ totals = claude_session_totals(path)
39
+ agent = totals.get("agentName")
40
+ if agent:
41
+ by_agent[agent] = (sid, path)
42
+
43
+ # Lead.
44
+ if lead_path is not None:
45
+ totals = claude_session_totals(lead_path)
46
+ state["leadUsage"] = usage_block(totals, source="claude-jsonl")
47
+ state["leadUsage"]["sessionId"] = lead_sid
48
+ else:
49
+ state["leadUsage"] = na_block(
50
+ f"lead session jsonl not found under {claude_project_dir(cwd)} (sessionId={lead_sid})"
51
+ )
52
+
53
+ # Workers.
54
+ for worker in state.get("workers", []):
55
+ worker_id = worker.get("workerId")
56
+ agent = worker.get("agent")
57
+ # Subagent agentName convention: workerId itself is "claude" but agentName is "claude-worker"; report-writer == "report-writer".
58
+ agent_name_candidates = []
59
+ if worker_id:
60
+ agent_name_candidates.append(worker_id)
61
+ if not worker_id.endswith("-worker") and worker_id != "report-writer":
62
+ agent_name_candidates.append(f"{worker_id}-worker")
63
+
64
+ # Claude wrapper jsonl (also for codex/gemini-worker, since these are Claude subagents).
65
+ wrapper = None
66
+ for cand in agent_name_candidates:
67
+ if cand in by_agent:
68
+ wrapper = by_agent[cand]
69
+ break
70
+ if wrapper is None:
71
+ worker["usage"] = na_block(f"no Claude subagent jsonl found with agentName matching {agent_name_candidates}")
72
+ continue
73
+ sid, path = wrapper
74
+ totals = claude_session_totals(path)
75
+ block = usage_block(totals, source="claude-jsonl")
76
+ block["sessionId"] = sid
77
+
78
+ # For codex/gemini workers, also try to find the underlying CLI session
79
+ # within the wrapper subagent's time window.
80
+ wrapper_start = totals.get("startedAt") or ""
81
+ wrapper_end = totals.get("endedAt") or ""
82
+ if agent in ("codex", "gemini"):
83
+ if agent == "codex":
84
+ cli = find_codex_session(cwd, wrapper_start, wrapper_end)
85
+ cli_totals = codex_session_total(cli) if cli else None
86
+ else:
87
+ cli = find_gemini_session(cwd, wrapper_start, wrapper_end)
88
+ cli_totals = gemini_session_total(cli) if cli else None
89
+ if cli is None:
90
+ block["cliNote"] = f"{agent} CLI session not found in wrapper window (fallback or never invoked)"
91
+ else:
92
+ block["cliSessionPath"] = str(cli)
93
+ if cli_totals.get("model"):
94
+ block["cliModel"] = cli_totals["model"]
95
+ if cli_totals.get("available"):
96
+ block["cliTotalTokens"] = cli_totals["totalTokens"]
97
+ if agent == "codex":
98
+ cost = codex_cost_usd(
99
+ cli_totals.get("model"),
100
+ cli_totals.get("inputTokens", 0) or 0,
101
+ cli_totals.get("cachedInputTokens", 0) or 0,
102
+ cli_totals.get("outputTokens", 0) or 0,
103
+ )
104
+ else:
105
+ cost = gemini_cost_usd(
106
+ cli_totals.get("model"),
107
+ cli_totals.get("inputTokens", 0) or 0,
108
+ cli_totals.get("outputTokens", 0) or 0,
109
+ )
110
+ if cost is not None:
111
+ block["cliEstimatedCostUsd"] = cost
112
+ else:
113
+ block["cliNote"] = f"{agent} CLI session found but no usage recorded (likely errored before completion)"
114
+ worker["usage"] = block
115
+
116
+ # Aggregate summary.
117
+ lead = state.get("leadUsage") or {}
118
+ workers = state.get("workers", [])
119
+ lead_total = lead.get("totalTokens", 0) or 0
120
+ lead_billable = lead.get("billableEquivalentTokens", 0) or 0
121
+ lead_cost = lead.get("estimatedCostUsd", 0) or 0
122
+ worker_total = sum((w.get("usage") or {}).get("totalTokens", 0) or 0 for w in workers)
123
+ worker_billable = sum((w.get("usage") or {}).get("billableEquivalentTokens", 0) or 0 for w in workers)
124
+ worker_cost = sum((w.get("usage") or {}).get("estimatedCostUsd", 0) or 0 for w in workers)
125
+ cli_cost = sum((w.get("usage") or {}).get("cliEstimatedCostUsd", 0) or 0 for w in workers)
126
+ state["usageSummary"] = {
127
+ "leadTotalTokens": lead_total,
128
+ "workerTotalTokens": worker_total,
129
+ "grandTotalTokens": lead_total + worker_total,
130
+ "leadBillableEquivalentTokens": lead_billable,
131
+ "workerBillableEquivalentTokens": worker_billable,
132
+ "grandBillableEquivalentTokens": lead_billable + worker_billable,
133
+ "estimatedCostUsd": {
134
+ "lead": round(lead_cost, 4),
135
+ "claudeWorkers": round(worker_cost, 4),
136
+ "cliWorkers": round(cli_cost, 4),
137
+ "grandTotal": round(lead_cost + worker_cost + cli_cost, 4),
138
+ },
139
+ "collectedAt": utc_now(),
140
+ "teamName": team_name,
141
+ "sessionsFound": len(claude_sessions),
142
+ "definitions": {
143
+ "totalTokens": "Sum of input + output + cache_creation + cache_read tokens (raw processed volume; matches Anthropic API breakdown). Cache reads are 95%+ in long sessions.",
144
+ "billableEquivalentTokens": "Tokens normalized to base-input-price units (cache_creation x1.25, cache_read x0.1, output x5). Useful when comparing sessions across models or to gauge cost.",
145
+ "estimatedCostUsd": "USD cost using public list pricing for the model recorded in the session. cliWorkers covers Codex/Gemini CLI calls billed under those providers.",
146
+ },
147
+ }
148
+ return state
149
+
150
+
151
+ def _infer_project_root(team_state_path: Path, state: dict) -> Path:
152
+ rel = state.get("runDirectoryPath") or ""
153
+ p = team_state_path.resolve().parent
154
+ while p != p.parent:
155
+ if rel and (p / rel).is_dir():
156
+ return p
157
+ if (p / ".project-docs").is_dir():
158
+ return p
159
+ p = p.parent
160
+ raise SystemExit(f"could not infer project root from {team_state_path}")
161
+
@@ -0,0 +1,77 @@
1
+ """Gemini CLI session collectors."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ from pathlib import Path
6
+ from .paths import GEMINI_TMP
7
+
8
+
9
+ def gemini_session_total(json_path: Path) -> dict:
10
+ try:
11
+ data = json.loads(json_path.read_text())
12
+ except (OSError, json.JSONDecodeError):
13
+ return {"totalTokens": 0, "available": False}
14
+ msgs = data.get("messages") or []
15
+ total = 0
16
+ inp = out = cached = thoughts = tool = 0
17
+ model = None
18
+ for m in msgs:
19
+ tok = m.get("tokens") or {}
20
+ total += tok.get("total", 0) or 0
21
+ inp += tok.get("input", 0) or 0
22
+ out += tok.get("output", 0) or 0
23
+ cached += tok.get("cached", 0) or 0
24
+ thoughts += tok.get("thoughts", 0) or 0
25
+ tool += tok.get("tool", 0) or 0
26
+ if model is None and m.get("model"):
27
+ model = m["model"]
28
+ return {
29
+ "totalTokens": total,
30
+ "inputTokens": inp,
31
+ "outputTokens": out,
32
+ "cachedTokens": cached,
33
+ "thoughtsTokens": thoughts,
34
+ "toolTokens": tool,
35
+ "model": model,
36
+ "startedAt": data.get("startTime"),
37
+ "endedAt": data.get("lastUpdated"),
38
+ "available": True,
39
+ }
40
+
41
+
42
+ def find_gemini_session(project_root: Path, started_at: str, ended_at: str) -> Path | None:
43
+ """Find the gemini chat session whose first user message references the project."""
44
+ if not GEMINI_TMP.is_dir() or not started_at or not ended_at:
45
+ return None
46
+ project_str = str(project_root)
47
+ candidates: list[tuple[str, Path]] = []
48
+ for p in GEMINI_TMP.glob("*/chats/session-*.json"):
49
+ try:
50
+ data = json.loads(p.read_text())
51
+ except (OSError, json.JSONDecodeError):
52
+ continue
53
+ start = data.get("startTime") or ""
54
+ if not (started_at <= start <= ended_at):
55
+ continue
56
+ # Match by content: first user message should mention project root or task path.
57
+ msgs = data.get("messages") or []
58
+ for m in msgs:
59
+ if m.get("type") != "user":
60
+ continue
61
+ content = m.get("content") or []
62
+ text = ""
63
+ if isinstance(content, list):
64
+ for block in content:
65
+ if isinstance(block, dict) and block.get("text"):
66
+ text = block["text"]
67
+ break
68
+ elif isinstance(content, str):
69
+ text = content
70
+ if project_str in text:
71
+ candidates.append((start, p))
72
+ break
73
+ if not candidates:
74
+ return None
75
+ candidates.sort()
76
+ return candidates[-1][1]
77
+
@@ -0,0 +1,18 @@
1
+ """JSONL reading helpers used by all session collectors."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ from pathlib import Path
6
+ from typing import Iterable
7
+
8
+
9
+ def iter_jsonl(path: Path) -> Iterable[dict]:
10
+ with path.open() as fh:
11
+ for line in fh:
12
+ line = line.strip()
13
+ if not line:
14
+ continue
15
+ try:
16
+ yield json.loads(line)
17
+ except json.JSONDecodeError:
18
+ continue
@@ -0,0 +1,22 @@
1
+ """Filesystem locations for agent session transcripts and time helpers."""
2
+ from __future__ import annotations
3
+
4
+ from datetime import datetime, timezone
5
+ from pathlib import Path
6
+
7
+
8
+ HOME = Path.home()
9
+ CLAUDE_PROJECTS = HOME / ".claude" / "projects"
10
+ CODEX_SESSIONS = HOME / ".codex" / "sessions"
11
+ GEMINI_TMP = HOME / ".gemini" / "tmp"
12
+
13
+
14
+ def utc_now() -> str:
15
+ return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
16
+
17
+
18
+ def claude_project_dir(cwd: Path) -> Path:
19
+ # Claude Code encodes cwd by replacing "/" with "-" (leading slash → leading "-").
20
+ encoded = "-" + str(cwd).strip("/").replace("/", "-")
21
+ return CLAUDE_PROJECTS / encoded
22
+
@@ -0,0 +1,71 @@
1
+ """Public list pricing tables and per-provider cost helpers."""
2
+ from __future__ import annotations
3
+
4
+
5
+ # Public list pricing (USD per 1M tokens). Used for cost estimation only.
6
+ # Update when Anthropic / OpenAI / Google change pricing.
7
+ # Anthropic billing ratios relative to base input: cache_creation=1.25x, cache_read=0.1x, output=5x.
8
+ CLAUDE_PRICING = {
9
+ # model substring -> (input, cache_creation, cache_read, output) USD/1M
10
+ "opus-4": (15.0, 18.75, 1.50, 75.0),
11
+ "sonnet-4": (3.0, 3.75, 0.30, 15.0),
12
+ "haiku-4": (1.0, 1.25, 0.10, 5.0),
13
+ "opus-3": (15.0, 18.75, 1.50, 75.0),
14
+ "sonnet-3": (3.0, 3.75, 0.30, 15.0),
15
+ "haiku-3": (0.80, 1.0, 0.08, 4.0),
16
+ }
17
+
18
+ CODEX_PRICING = {
19
+ # model substring -> (input USD/1M, cached_input USD/1M, output USD/1M)
20
+ "gpt-5": (1.25, 0.125, 10.0),
21
+ "gpt-4": (2.50, 0.625, 10.0),
22
+ }
23
+
24
+ GEMINI_PRICING = {
25
+ # model substring -> (input USD/1M, output USD/1M); cached not separately priced for short runs
26
+ "pro": (1.25, 5.0),
27
+ "flash": (0.075, 0.30),
28
+ "auto": (1.25, 5.0), # treat unknown as pro
29
+ }
30
+
31
+
32
+ def _match_pricing(model: str | None, table: dict) -> tuple | None:
33
+ if not model:
34
+ return None
35
+ m = model.lower()
36
+ for key, val in table.items():
37
+ if key in m:
38
+ return val
39
+ return None
40
+
41
+
42
+ def claude_billable_equivalent(input_t: int, cache_create_t: int, cache_read_t: int, output_t: int) -> int:
43
+ """Sum normalized to base-input units (cache_creation 1.25x, cache_read 0.1x, output 5x)."""
44
+ return int(round(input_t + 1.25 * cache_create_t + 0.1 * cache_read_t + 5.0 * output_t))
45
+
46
+
47
+ def claude_cost_usd(model: str | None, input_t: int, cache_create_t: int, cache_read_t: int, output_t: int) -> float | None:
48
+ p = _match_pricing(model, CLAUDE_PRICING)
49
+ if p is None:
50
+ return None
51
+ pi, pcc, pcr, po = p
52
+ return round((input_t * pi + cache_create_t * pcc + cache_read_t * pcr + output_t * po) / 1_000_000, 4)
53
+
54
+
55
+ def codex_cost_usd(model: str | None, input_t: int, cached_input_t: int, output_t: int) -> float | None:
56
+ p = _match_pricing(model, CODEX_PRICING)
57
+ if p is None:
58
+ return None
59
+ pi, pci, po = p
60
+ # Codex `input_tokens` already includes cached; subtract cached and price the rest at full input.
61
+ fresh = max(0, input_t - cached_input_t)
62
+ return round((fresh * pi + cached_input_t * pci + output_t * po) / 1_000_000, 4)
63
+
64
+
65
+ def gemini_cost_usd(model: str | None, input_t: int, output_t: int) -> float | None:
66
+ p = _match_pricing(model, GEMINI_PRICING)
67
+ if p is None:
68
+ return None
69
+ pi, po = p
70
+ return round((input_t * pi + output_t * po) / 1_000_000, 4)
71
+
@@ -0,0 +1,64 @@
1
+ """Final-report substitution helpers (writes Phase 7 placeholders into final-report.md)."""
2
+ from __future__ import annotations
3
+
4
+ from pathlib import Path
5
+
6
+
7
+ def _format_int(n) -> str:
8
+ try:
9
+ return f"{int(n):,}"
10
+ except (TypeError, ValueError):
11
+ return "0"
12
+
13
+
14
+ def _format_usd(v) -> str:
15
+ try:
16
+ return f"${float(v):.2f}"
17
+ except (TypeError, ValueError):
18
+ return "$0.00"
19
+
20
+
21
+ def substitute_final_report(report_path: Path, state: dict) -> int:
22
+ """Replace token-usage placeholders in the final report file with concrete
23
+ values from the freshly computed usageSummary.
24
+
25
+ Returns the number of placeholder occurrences replaced. If the report file
26
+ does not exist, returns -1 without raising. If any required placeholder is
27
+ still present after substitution attempts (e.g. usageSummary missing), the
28
+ function still writes what it can and returns the count.
29
+ """
30
+ if not report_path.is_file():
31
+ return -1
32
+
33
+ summary = state.get("usageSummary") or {}
34
+ cost = summary.get("estimatedCostUsd") or {}
35
+ lead_cost = cost.get("lead") or 0
36
+ worker_cost = cost.get("claudeWorkers") or 0
37
+ cli_cost = cost.get("cliWorkers") or 0
38
+ grand_cost = lead_cost + worker_cost # CLI tracked on its own row per template
39
+
40
+ mapping = {
41
+ "{{LEAD_TOTAL_TOKENS}}": _format_int(summary.get("leadTotalTokens", 0)),
42
+ "{{LEAD_BILLABLE_TOKENS}}": _format_int(summary.get("leadBillableEquivalentTokens", 0)),
43
+ "{{LEAD_COST_USD}}": _format_usd(lead_cost),
44
+ "{{WORKER_TOTAL_TOKENS}}": _format_int(summary.get("workerTotalTokens", 0)),
45
+ "{{WORKER_BILLABLE_TOKENS}}": _format_int(summary.get("workerBillableEquivalentTokens", 0)),
46
+ "{{WORKER_COST_USD}}": _format_usd(worker_cost),
47
+ "{{GRAND_TOTAL_TOKENS}}": _format_int(summary.get("grandTotalTokens", 0)),
48
+ "{{GRAND_BILLABLE_TOKENS}}": _format_int(summary.get("grandBillableEquivalentTokens", 0)),
49
+ "{{GRAND_COST_USD}}": _format_usd(grand_cost),
50
+ "{{CLI_COST_USD}}": _format_usd(cli_cost),
51
+ }
52
+
53
+ text = report_path.read_text()
54
+ replaced = 0
55
+ for needle, value in mapping.items():
56
+ count = text.count(needle)
57
+ if count:
58
+ text = text.replace(needle, value)
59
+ replaced += count
60
+
61
+ if replaced:
62
+ report_path.write_text(text)
63
+ return replaced
64
+
@@ -0,0 +1,273 @@
1
+ # OKSTRA Task Brief
2
+
3
+ <!--
4
+ 이 brief은 okstra 워커(Claude/Codex/Gemini)가 코드베이스/도메인 사전지식 없이 분석할 수 있도록 준비하는 단일 source-of-truth 문서입니다.
5
+
6
+ 원칙:
7
+ 1. 워커는 외부 링크에 접근할 수 없음 → 모든 1차 증거는 inline으로 박을 것 (fenced code block).
8
+ 2. 워커는 도메인 용어를 모름 → Domain Glossary로 핵심 용어를 사전 정의.
9
+ 3. 운영자(operator)용 정보(okstra.sh 명령, working directory)는 brief에 두지 말 것 — 별도 runbook으로.
10
+ 4. Task Type에 따라 작성해야 할 섹션이 다름 → 해당 태스크의 conditional 블록을 채울 것.
11
+ 5. 비어 있는 섹션은 `_N/A — <reason>_`로 명시. 침묵은 워커 혼란의 원인.
12
+ -->
13
+
14
+ ## Identity
15
+
16
+ | Field | Value |
17
+ |-------|-------|
18
+ | Brief Title | `<task-group>-<TASK-ID>-<short-slug>` |
19
+ | Project ID | `<project-id>` |
20
+ | Task Group | `<task-group>` |
21
+ | Task ID | `<TASK-ID>` |
22
+ | Task Type | `requirements-discovery` \| `error-analysis` \| `implementation-planning` \| `final-verification` |
23
+ | Issue / Ticket | `<TASK-ID> · <title in original language> (English: <translation if non-English>)` |
24
+ | Requested Outcome | <한 문장. 이 run이 산출해야 할 핵심 결과물> |
25
+
26
+ ---
27
+
28
+ ## Request Summary
29
+
30
+ - **What is being requested or changed?**
31
+ <한 문단 또는 bullet 3-5개>
32
+ - **Why now?**
33
+ <비즈니스/기술 트리거. 마감일, 인시던트, 의존 태스크 등>
34
+ - **New / Continuation / Reopened?**
35
+ <One of the three + 직전 run 식별자(있다면)>
36
+ - **What decision should this run produce?**
37
+ <이 run이 답해야 할 핵심 결정 1-3개. 모호하면 워커가 산만해짐>
38
+
39
+ ---
40
+
41
+ ## Inline Evidence
42
+
43
+ <!--
44
+ 워커는 외부 URL/Notion/Linear/Slack에 접근할 수 없습니다. 1차 증거는 모두 여기에 inline 박을 것.
45
+ -->
46
+
47
+ ### Symptom / Sample Payload
48
+
49
+ ```
50
+ <관찰된 현상, 응답 페이로드 샘플, UI 스크린샷 caption 등 — 텍스트로>
51
+ ```
52
+
53
+ ### Logs / Stack Trace (해당되는 경우)
54
+
55
+ ```
56
+ <로그 발췌. 민감정보는 마스킹>
57
+ ```
58
+
59
+ ### Relevant Code Excerpts
60
+
61
+ ```typescript
62
+ // path: src/domains/upload/dto/upload-job.view.ts:23-45
63
+ <코드 발췌 — 워커가 파일 전체를 읽지 않아도 핵심을 파악할 수 있어야 함>
64
+ ```
65
+
66
+ ### External Doc Excerpts (Notion/Linear/Confluence 발췌)
67
+
68
+ > <원문 인용. 링크는 보조이고 인용이 본체>
69
+ > -- Source: <doc title + section>
70
+
71
+ ---
72
+
73
+ ## Domain Glossary
74
+
75
+ <!--
76
+ Codex/Gemini는 이 코드베이스를 모릅니다. 핵심 용어 5-15개를 한 문장씩 정의.
77
+ 없는 용어를 워커가 추측하면 분석 품질이 즉시 떨어집니다.
78
+ -->
79
+
80
+ | Term | Definition |
81
+ |------|-----------|
82
+ | `<도메인 용어 1>` | <1-2 문장 정의> |
83
+ | `<도메인 용어 2>` | <1-2 문장 정의> |
84
+ | `<코드 식별자 1>` | <역할 + 위치 + 다른 entity와의 관계> |
85
+
86
+ ---
87
+
88
+ ## Current Context
89
+
90
+ - **Current behavior or state**:
91
+ <지금 시스템이 어떻게 동작하는가>
92
+ - **Desired behavior or outcome**:
93
+ <어떻게 동작해야 하는가>
94
+ - **Existing related implementation**:
95
+ - `<file-path>:<line-range>` — <역할 한 줄>
96
+ - **Related code paths** (참조용 — 직접 발췌는 Inline Evidence에):
97
+ - `<path>`
98
+ - `<path>`
99
+
100
+ ---
101
+
102
+ ## Out of Scope
103
+
104
+ <!--
105
+ 명시적 제외 목록. 워커 scope-creep 방지의 핵심.
106
+ -->
107
+
108
+ 이 run에서는 **다루지 않을** 것들:
109
+
110
+ - <out-of-scope item 1 + 제외 이유>
111
+ - <out-of-scope item 2 + 제외 이유>
112
+ - <별도 ticket/run으로 다룰 항목 — ticket ID 명시>
113
+
114
+ ---
115
+
116
+ ## Task-Type Focus
117
+
118
+ <!--
119
+ 아래 두 블록 중 현재 task-type에 해당하는 것만 채우고, 나머지는 삭제하세요.
120
+ -->
121
+
122
+ ### If `requirements-discovery`
123
+
124
+ - **Why might this be a bugfix?**
125
+ <증거 또는 "weak signal — none observed">
126
+ - **Why might this be a feature/improvement?**
127
+ <증거>
128
+ - **Why might this be a refactor/ops-change?**
129
+ <증거>
130
+ - **Classification blockers** — 무엇이 분류 확신을 막고 있는가?
131
+ <evidence gap을 구체적으로>
132
+
133
+ ### If `error-analysis`
134
+
135
+ - **Symptom** — 사용자/시스템이 무엇을 관찰하는가?
136
+ - **Reproduction Steps** — 1-2-3 형식. 환경/사전조건 명시.
137
+ ```
138
+ 1. <env: staging | local | prod>
139
+ 2. <action>
140
+ 3. <action>
141
+ 4. Expected: <…> / Actual: <…>
142
+ ```
143
+ - **Frequency** — 항상 / 간헐적 / 특정 조건에서만 / 1회성?
144
+ - **Blast Radius** — 영향받는 사용자/요청/data 범위
145
+ - **Suspected Cause(s)** — 현재까지의 가설. 각각 신뢰도(High/Med/Low)와 근거.
146
+ - **What has been ruled out** — 이미 검증한 false leads (워커가 같은 길 가지 않게)
147
+
148
+ ---
149
+
150
+ ## Constraints and Risks
151
+
152
+ - **Business constraints**: <e.g. 개인정보, SLA — 외부 승인/권한 항목은 적지 말 것 (사용자가 모든 권한을 가진다고 가정)>
153
+ - **Technical constraints**: <e.g. backward-compat, 기존 client, schema>
154
+ - **Delivery constraints**: <마감, 의존 배포, 롤아웃 게이트 — 단, 외부 승인 대기·권한 확인은 일정 요소로 적지 말 것>
155
+ - **Approval / review checkpoints**: _N/A — 사용자가 모든 권한·승인 권한을 보유한다고 가정 (okstra 기본 규칙). 정말로 외부 차단 요소가 있을 때만 구체적으로 기술._
156
+ - **Known assumptions**: <명시적 가정 — 워커가 검증할 항목>
157
+ - **Open uncertainties**: <확인 필요한 것 — 답이 나오면 결정이 잠금 해제됨. 외부 권한·승인 관련 항목은 제외>
158
+
159
+ ---
160
+
161
+ ## Configuration / Deployment Context
162
+
163
+ <!--
164
+ 이 섹션은 *해당되는 경우에만* 채우세요. DTO 변경, 순수 로직 수정, 문서 작업 등은 보통 _N/A_입니다.
165
+ 무관할 때는 본 섹션 전체를 다음 한 줄로 대체:
166
+
167
+ _N/A — this task does not touch config or deployment._
168
+ -->
169
+
170
+ - **Config files in scope**: `<path>` — <어떤 키가 관련>
171
+ - **Current observed values**: <key=value, 출처 명시>
172
+ - **Expected values / invariants**: <변하면 안 되는 것 + 변해야 하는 것>
173
+ - **Deployment manifests in scope**: `<helm chart / k8s manifest / terraform path>`
174
+ - **Rollout invariants**: <e.g. zero-downtime, version skew window>
175
+
176
+ ---
177
+
178
+ ## External Resource Hints (for Lead pre-fetch)
179
+
180
+ <!--
181
+ okstra Lead가 워커 프롬프트에 inline으로 박아야 할 외부 자원 목록.
182
+ 워커는 MCP/외부 도구에 직접 접근하지 않으므로 Lead가 사전에 snapshot해서 prompt에 embed합니다.
183
+ -->
184
+
185
+ | Resource | Type | Why needed |
186
+ |----------|------|-----------|
187
+ | `<table-name>` | MySQL schema | <어느 분석에서 schema 검증 필요> |
188
+ | `<library@version>` | Library docs (`mcp__test-context7`) | <API 시그니처 확인용> |
189
+ | `<aws-doc-keyword>` | AWS knowledge base | <설계 검증용> |
190
+ | `/Volumes/.../app/<sibling-project>/<path>` | Cross-project file | <reference impl> |
191
+
192
+ 자원 없음이 명확하면 본 섹션을 `_N/A_`로 표시.
193
+
194
+ ---
195
+
196
+ ## Related Tasks
197
+
198
+ <!--
199
+ 관계 라벨을 반드시 명시: blocker | blocked-by | sibling | follow-up | duplicate | shares-codepath
200
+ -->
201
+
202
+ | Task | Relation | Note |
203
+ |------|----------|------|
204
+ | `DEV-XXXX` | `sibling` | 같은 epic, 같은 코드베이스, 동시 변경 가능성 |
205
+ | `DEV-YYYY` | `blocker` | 이 task는 DEV-YYYY 완료 후에만 시작 가능 |
206
+
207
+ ---
208
+
209
+ ## Definition of Done (for this run)
210
+
211
+ <!--
212
+ Requested Outcome은 큰 그림. 여기서는 "이 run의 산출물이 충족해야 할 검증 가능한 조건"을 적습니다.
213
+ -->
214
+
215
+ 이 run의 final report는 다음을 모두 충족해야 합니다:
216
+
217
+ - [ ] <결정 항목 1에 대한 명시적 답변 또는 "결정 불가 + 이유">
218
+ - [ ] <결정 항목 2에 대한 명시적 답변>
219
+ - [ ] <남은 blocking question 목록>
220
+ - [ ] <recommended next phase + reasoning>
221
+
222
+ ---
223
+
224
+ ## Questions for Workers
225
+
226
+ <!--
227
+ P0(반드시 답해야 함) / P1(가능하면 답) / P2(보너스)로 우선순위 명시.
228
+ -->
229
+
230
+ 1. **[P0]** <핵심 질문 1>
231
+ 2. **[P0]** <핵심 질문 2>
232
+ 3. **[P1]** <보조 질문>
233
+ 4. **[P2]** <탐색적 질문>
234
+
235
+ ---
236
+
237
+ ## Expected Outputs
238
+
239
+ | Category | Expected content |
240
+ |----------|-----------------|
241
+ | Root cause / plan options / blockers | <ranked list of options or candidates> |
242
+ | Missing information | <what additional discovery is required> |
243
+ | Risks | <ordered by severity> |
244
+ | Recommended next actions | <phase routing + concrete next step> |
245
+
246
+ ---
247
+
248
+ ## Notes for Lead (synthesis emphasis)
249
+
250
+ <!--
251
+ 워커 셀렉션(어느 모델을 쓸지)은 task-manifest.json의 recommendedWorkers에서 자동 결정되므로 여기에 적지 마세요.
252
+ 이 섹션은 *합성 시 강조점*만 다룹니다.
253
+ -->
254
+
255
+ - **Synthesis priority**: <e.g. 안전성 vs 속도, public/private 경계 정확성, backward compat>
256
+ - **Where worker disagreement matters most**: <consensus가 가장 중요한 지점>
257
+ - **Where reduced confidence is acceptable**: <탐색 영역 — 가설만 나와도 충분>
258
+
259
+ ---
260
+
261
+ ## Brief Hygiene Checklist
262
+
263
+ 작성 후 자가 점검:
264
+
265
+ - [ ] 모든 외부 링크에 대해 inline 발췌가 박혀 있다
266
+ - [ ] Domain Glossary에 코드베이스 무지식 워커가 알아야 할 용어가 모두 있다
267
+ - [ ] Out of Scope가 명시적으로 적혀 있다
268
+ - [ ] Task-Type Focus의 해당 블록만 남아 있다 (다른 블록은 삭제됨)
269
+ - [ ] error-analysis라면 Reproduction Steps가 채워져 있다
270
+ - [ ] Definition of Done이 검증 가능한 체크리스트 형태다
271
+ - [ ] Configuration / Deployment 섹션은 해당 시에만 내용이 있고, 무관 시 `_N/A_`로 닫혀 있다
272
+ - [ ] 한글/영문/원어 ticket title 모두 식별 가능하다
273
+ - [ ] Related Tasks에 관계 라벨이 명시되어 있다