okstra 0.20.0 → 0.21.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 (39) hide show
  1. package/docs/kr/architecture.md +1 -1
  2. package/docs/kr/performance-improvement-plan-v2.md +330 -0
  3. package/docs/kr/performance-improvement-plan.md +125 -0
  4. package/docs/project-structure-overview.md +386 -0
  5. package/docs/superpowers/plans/2026-05-14-convergence-queue-pruning.md +1568 -0
  6. package/package.json +1 -1
  7. package/runtime/BUILD.json +2 -2
  8. package/runtime/agents/SKILL.md +7 -1
  9. package/runtime/agents/workers/codex-worker.md +6 -4
  10. package/runtime/agents/workers/gemini-worker.md +6 -4
  11. package/runtime/agents/workers/report-writer-worker.md +4 -0
  12. package/runtime/bin/okstra-codex-exec.sh +36 -6
  13. package/runtime/bin/okstra-gemini-exec.sh +6 -8
  14. package/runtime/prompts/profiles/final-verification.md +8 -2
  15. package/runtime/prompts/profiles/implementation-planning.md +1 -1
  16. package/runtime/prompts/profiles/release-handoff.md +26 -28
  17. package/runtime/prompts/profiles/requirements-discovery.md +1 -1
  18. package/runtime/python/okstra_ctl/render.py +78 -4
  19. package/runtime/python/okstra_ctl/run.py +0 -6
  20. package/runtime/python/okstra_ctl/run_context.py +5 -0
  21. package/runtime/python/okstra_ctl/workflow.py +8 -7
  22. package/runtime/python/okstra_ctl/worktree.py +155 -15
  23. package/runtime/python/okstra_token_usage/blocks.py +0 -2
  24. package/runtime/python/okstra_token_usage/claude.py +0 -2
  25. package/runtime/skills/okstra-brief/SKILL.md +523 -0
  26. package/runtime/skills/okstra-convergence/SKILL.md +149 -37
  27. package/runtime/skills/okstra-report-writer/SKILL.md +8 -6
  28. package/runtime/templates/prd/brief.template.md +12 -0
  29. package/runtime/templates/project-docs/task-index.template.md +12 -0
  30. package/runtime/templates/reports/error-analysis-input.template.md +12 -0
  31. package/runtime/templates/reports/final-report.template.md +39 -12
  32. package/runtime/templates/reports/final-verification-input.template.md +22 -0
  33. package/runtime/templates/reports/implementation-input.template.md +12 -0
  34. package/runtime/templates/reports/implementation-planning-input.template.md +12 -0
  35. package/runtime/templates/reports/quick-input.template.md +12 -0
  36. package/runtime/templates/reports/release-handoff-input.template.md +23 -10
  37. package/runtime/templates/reports/schedule.template.md +12 -0
  38. package/runtime/templates/reports/settings.template.json +83 -30
  39. package/runtime/templates/reports/task-brief.template.md +12 -0
@@ -124,23 +124,24 @@ PHASE_RULES: dict[str, dict[str, str]] = {
124
124
  },
125
125
  "release-handoff": {
126
126
  "allowed": (
127
- " - entering this phase only when the cited final-verification report's verdict is exactly `accepted`\n"
128
- " - asking the user (via `AskUserQuestion` / interactive prompt) which delivery action to take: `commit only`, `commit + PR`, or `skip` (end the run)\n"
127
+ " - entering this phase only when the cited final-verification report's `Verdict Token` is exactly `accepted`\n"
128
+ " - asking the user (via `AskUserQuestion` / interactive prompt) which delivery action to take: `local only`, `push + PR`, or `skip` (end the run)\n"
129
129
  " - asking the user to pick a PR base branch from `staging` | `preprod` | `prod` | `main` | `dev` | a user-supplied branch name\n"
130
- " - drafting commit message(s) and PR body **inline as the Claude lead** (no drafter worker, no `Agent` dispatch); the lead reviews its own draft with the user before any git command runs\n"
131
- " - local git operations: `git status`, `git diff`, `git log`, `git add`, `git commit -m`\n"
130
+ " - drafting PR title and PR body **inline as the Claude lead** (no drafter worker, no `Agent` dispatch); the lead reviews its own draft with the user before any mutating git / gh command runs\n"
131
+ " - read-only git inspection: `git status`, `git diff`, `git log`, `git rev-parse`\n"
132
132
  " - pushing the current feature branch to its origin remote via `git push -u origin <current-branch>` (the feature branch only — NEVER the base branch)\n"
133
133
  " - creating a pull request via `gh pr create --base <chosen-base> --head <current-branch>`; if a PR with the same head already exists, surface its URL and skip creation\n"
134
134
  " - the lead writes the final report directly (no `Report writer worker` dispatch); the report still conforms to the standard final-report template"
135
135
  ),
136
136
  "forbidden": (
137
- " - entering this phase when the cited final-verification verdict is `conditional-accept` or `blocked`, or when no final-verification report is cited\n"
138
- " - any source-code edit, refactor, or scope expansion beyond what is strictly needed to author commit messages / PR descriptions (the changes themselves are inherited from prior `implementation` runs)\n"
137
+ " - entering this phase when the cited final-verification `Verdict Token` is `conditional-accept` or `blocked`, or when no final-verification report is cited\n"
138
+ " - any source-code edit, refactor, or scope expansion beyond what is strictly needed to author PR descriptions (the changes themselves are inherited from prior `implementation` runs)\n"
139
+ " - local commit commands (`git add`, `git commit`, `git stash`, `git restore --staged`); commits are produced by `implementation`, not release-handoff\n"
139
140
  " - `git push --force`, `git push --force-with-lease`, or any rewriting of remote history\n"
140
141
  " - pushing directly to a base branch (`main`, `master`, `prod`, `preprod`, `staging`, `dev`, or any branch the user named as the PR base)\n"
141
142
  " - bypassing git hooks (`--no-verify`, `-n`), bypassing GPG signing, or otherwise disabling repo-configured safeguards\n"
142
143
  " - release-publishing commands: `gh release`, `npm publish`, `cargo publish`, `pip publish`, `docker push`, `terraform apply`, `kubectl apply` against non-local clusters\n"
143
- " - executing any command the user did NOT select (e.g. if the user picked `commit only`, opening a PR is forbidden; if the user picked `skip`, the run ends without git commands)\n"
144
+ " - executing any command the user did NOT select (e.g. if the user picked `local only`, opening a PR is forbidden; if the user picked `skip`, the run ends without git commands)\n"
144
145
  " - `TeamCreate`, `Agent(...)` worker dispatch, parallel sub-agent fan-out, or any team-mode orchestration — this phase runs single-lead\n"
145
146
  " - silently retrying a failed git/gh command with weaker flags (e.g. retrying `git push` with `--force` after a non-fast-forward rejection)"
146
147
  ),
@@ -34,6 +34,8 @@ from __future__ import annotations
34
34
 
35
35
  import json
36
36
  import os
37
+ import shutil
38
+ import stat
37
39
  import subprocess
38
40
  from dataclasses import dataclass
39
41
  from pathlib import Path
@@ -43,9 +45,6 @@ from .ids import _safe_fs_segment
43
45
  from . import worktree_registry
44
46
 
45
47
 
46
- OKSTRA_WORKTREES_RELATIVE = Path(".okstra/worktrees")
47
-
48
-
49
48
  # Project-root directories that hold okstra task state, ignored by git, or
50
49
  # otherwise required for the executor to operate but NOT carried across by
51
50
  # `git worktree add`. Each is symlinked from the MAIN worktree into the new
@@ -71,6 +70,35 @@ DEFAULT_WORKTREE_SYNC_DIRS: tuple[str, ...] = (
71
70
  )
72
71
 
73
72
 
73
+ # Project-root-relative FILES (not dirs) symlinked from MAIN → task worktree
74
+ # at provision time. Same symlink semantics as `DEFAULT_WORKTREE_SYNC_DIRS`:
75
+ # every task sees the live shared file. The split exists because the original
76
+ # `_link_sync_dirs` helper only walked directories — a `.env` (or any
77
+ # top-level file outside `.git`'s tracking) would otherwise be lost on every
78
+ # new worktree, blocking verifier dispatches that depend on environment-
79
+ # resolved secrets.
80
+ #
81
+ # Override precedence mirrors `DEFAULT_WORKTREE_SYNC_DIRS`:
82
+ # 1. `OKSTRA_WORKTREE_SYNC_FILES` env var (colon-separated, REPLACES).
83
+ # 2. `worktreeSyncFiles` array in `.project-docs/okstra/project.json`.
84
+ # 3. The built-in `DEFAULT_WORKTREE_SYNC_FILES` below.
85
+ DEFAULT_WORKTREE_SYNC_FILES: tuple[str, ...] = (
86
+ ".env",
87
+ )
88
+
89
+
90
+ # Project-root-relative files COPIED (not symlinked) from MAIN → task worktree
91
+ # at provision time, then `chmod 0o444` so the task cannot mutate the
92
+ # snapshot. Used for live-mutating fixtures (e.g. `classifications.db`)
93
+ # where the verifier needs to reproduce accuracy SQL against the same rows
94
+ # the executor saw, without sharing the writable handle that would corrupt
95
+ # the main worktree's copy. Default is empty — okstra has no opinion about
96
+ # which fixtures any given project relies on; opt in per-project via
97
+ # `worktreeSnapshotFiles` in `project.json` (or `OKSTRA_WORKTREE_SNAPSHOT_FILES`
98
+ # for one-off operator override).
99
+ DEFAULT_WORKTREE_SNAPSHOT_FILES: tuple[str, ...] = ()
100
+
101
+
74
102
  # Work-category → short branch prefix. Mirrors the values accepted by
75
103
  # `--work-category` (bugfix / feature / refactor / ops / improvement) and
76
104
  # falls back to `task` when the category is unset or unrecognised.
@@ -190,14 +218,14 @@ def _main_worktree_path(project_root: Path) -> Path:
190
218
  return project_root
191
219
 
192
220
 
193
- def _read_project_json_sync_dirs(project_root: Path) -> Optional[tuple[str, ...]]:
194
- """Read `worktreeSyncDirs` from `.project-docs/okstra/project.json`.
221
+ def _read_project_json_field(project_root: Path, field: str) -> Optional[tuple[str, ...]]:
222
+ """Read a string-array field from `.project-docs/okstra/project.json`.
195
223
 
196
224
  Returns None if the field is absent or the file cannot be parsed (so
197
225
  the caller falls back to defaults). Returns an empty tuple if the
198
226
  field is explicitly an empty array (caller treats this as "disable").
199
227
  A non-list value is treated as missing — we do not raise here because
200
- sync-dir resolution must never block worktree provisioning.
228
+ field resolution must never block worktree provisioning.
201
229
  """
202
230
  target = project_root / ".project-docs" / "okstra" / "project.json"
203
231
  if not target.is_file():
@@ -208,7 +236,7 @@ def _read_project_json_sync_dirs(project_root: Path) -> Optional[tuple[str, ...]
208
236
  return None
209
237
  if not isinstance(data, dict):
210
238
  return None
211
- value = data.get("worktreeSyncDirs")
239
+ value = data.get(field)
212
240
  if not isinstance(value, list):
213
241
  return None
214
242
  cleaned = tuple(
@@ -218,22 +246,68 @@ def _read_project_json_sync_dirs(project_root: Path) -> Optional[tuple[str, ...]
218
246
  return cleaned
219
247
 
220
248
 
221
- def _resolve_sync_dirs(project_root: Optional[Path] = None) -> tuple[str, ...]:
222
- """Return the list of project-root-relative dirs to symlink into the
223
- new worktree. Precedence: env var → project.json → built-in default.
224
- See the comment above `DEFAULT_WORKTREE_SYNC_DIRS` for full semantics.
249
+ def _read_project_json_sync_dirs(project_root: Path) -> Optional[tuple[str, ...]]:
250
+ """Back-compat shim preserves the old name used by external callers."""
251
+ return _read_project_json_field(project_root, "worktreeSyncDirs")
252
+
253
+
254
+ def _resolve_entries(
255
+ *,
256
+ env_var: str,
257
+ project_field: str,
258
+ default: tuple[str, ...],
259
+ project_root: Optional[Path],
260
+ ) -> tuple[str, ...]:
261
+ """Generic resolver shared by sync-dirs / sync-files / snapshot-files.
262
+
263
+ Precedence: env var (colon-separated, REPLACES) → project.json field →
264
+ built-in default. An empty env value or empty JSON array disables the
265
+ feature (returns `()`).
225
266
  """
226
- raw = os.environ.get("OKSTRA_WORKTREE_SYNC_DIRS")
267
+ raw = os.environ.get(env_var)
227
268
  if raw is not None:
228
269
  raw = raw.strip()
229
270
  if not raw:
230
271
  return ()
231
272
  return tuple(part for part in (p.strip() for p in raw.split(":")) if part)
232
273
  if project_root is not None:
233
- from_project = _read_project_json_sync_dirs(project_root)
274
+ from_project = _read_project_json_field(project_root, project_field)
234
275
  if from_project is not None:
235
276
  return from_project
236
- return DEFAULT_WORKTREE_SYNC_DIRS
277
+ return default
278
+
279
+
280
+ def _resolve_sync_dirs(project_root: Optional[Path] = None) -> tuple[str, ...]:
281
+ """Return the list of project-root-relative dirs to symlink into the
282
+ new worktree. Precedence: env var → project.json → built-in default.
283
+ See the comment above `DEFAULT_WORKTREE_SYNC_DIRS` for full semantics.
284
+ """
285
+ return _resolve_entries(
286
+ env_var="OKSTRA_WORKTREE_SYNC_DIRS",
287
+ project_field="worktreeSyncDirs",
288
+ default=DEFAULT_WORKTREE_SYNC_DIRS,
289
+ project_root=project_root,
290
+ )
291
+
292
+
293
+ def _resolve_sync_files(project_root: Optional[Path] = None) -> tuple[str, ...]:
294
+ """File-level counterpart to `_resolve_sync_dirs` (FU-V2)."""
295
+ return _resolve_entries(
296
+ env_var="OKSTRA_WORKTREE_SYNC_FILES",
297
+ project_field="worktreeSyncFiles",
298
+ default=DEFAULT_WORKTREE_SYNC_FILES,
299
+ project_root=project_root,
300
+ )
301
+
302
+
303
+ def _resolve_snapshot_files(project_root: Optional[Path] = None) -> tuple[str, ...]:
304
+ """Read-only snapshot file list (FU-V3)."""
305
+ return _resolve_entries(
306
+ env_var="OKSTRA_WORKTREE_SNAPSHOT_FILES",
307
+ project_field="worktreeSnapshotFiles",
308
+ default=DEFAULT_WORKTREE_SNAPSHOT_FILES,
309
+ project_root=project_root,
310
+ )
237
311
 
238
312
 
239
313
  def _link_sync_dirs(source_root: Path, worktree_path: Path) -> list[str]:
@@ -264,6 +338,63 @@ def _link_sync_dirs(source_root: Path, worktree_path: Path) -> list[str]:
264
338
  return notes
265
339
 
266
340
 
341
+ def _link_sync_files(source_root: Path, worktree_path: Path) -> list[str]:
342
+ """File-level counterpart to `_link_sync_dirs` (FU-V2).
343
+
344
+ Same skip rules: missing source → skipped silently; pre-existing dst
345
+ (tracked content checked out by `git worktree add`) → skipped to avoid
346
+ clobbering. Symlinks (not copies) keep secrets out of duplicate files —
347
+ the link points back to the MAIN worktree's `.env` so rotating the file
348
+ there is reflected in every active task without re-provisioning.
349
+ """
350
+ notes: list[str] = []
351
+ for rel in _resolve_sync_files(source_root):
352
+ src = (source_root / rel).resolve()
353
+ if not src.exists() or not src.is_file():
354
+ continue
355
+ dst = worktree_path / rel
356
+ if dst.exists() or dst.is_symlink():
357
+ continue
358
+ dst.parent.mkdir(parents=True, exist_ok=True)
359
+ os.symlink(src, dst)
360
+ notes.append(rel)
361
+ return notes
362
+
363
+
364
+ def _copy_snapshot_files(source_root: Path, worktree_path: Path) -> list[str]:
365
+ """Copy fixture files from MAIN → task worktree as read-only snapshots
366
+ (FU-V3).
367
+
368
+ Unlike `_link_sync_files`, this materialises a fresh on-disk copy and
369
+ chmods it to `0o444` so the verifier can read the same rows the
370
+ executor saw without sharing a writable handle that would corrupt the
371
+ main worktree's copy. Skip rules mirror the symlink helpers (missing
372
+ source → skipped silently; pre-existing dst → skipped to avoid
373
+ clobbering tracked content).
374
+ """
375
+ notes: list[str] = []
376
+ for rel in _resolve_snapshot_files(source_root):
377
+ src = (source_root / rel).resolve()
378
+ if not src.exists() or not src.is_file():
379
+ continue
380
+ dst = worktree_path / rel
381
+ if dst.exists() or dst.is_symlink():
382
+ continue
383
+ dst.parent.mkdir(parents=True, exist_ok=True)
384
+ shutil.copy2(src, dst)
385
+ try:
386
+ os.chmod(dst, stat.S_IRUSR | stat.S_IRGRP | stat.S_IROTH)
387
+ except OSError:
388
+ # chmod failure (exotic filesystems, root-squashed mounts) does
389
+ # not invalidate the snapshot itself — it just means the read-
390
+ # only contract is best-effort. Surface in notes so the operator
391
+ # can audit if a verifier later mutates the file.
392
+ notes.append(f"{rel} (rw-fallback)")
393
+ continue
394
+ notes.append(rel)
395
+ return notes
396
+
397
+
267
398
  def compute_worktree_path(
268
399
  *,
269
400
  project_id: str,
@@ -427,7 +558,16 @@ def provision_task_worktree(
427
558
  # Sync dirs sourced from the MAIN worktree so every task sees the
428
559
  # same shared state regardless of which checkout invoked okstra.
429
560
  linked = _link_sync_dirs(main_root, worktree_path)
430
- linked_suffix = f"; linked {', '.join(linked)}" if linked else ""
561
+ linked_files = _link_sync_files(main_root, worktree_path)
562
+ snapshot_files = _copy_snapshot_files(main_root, worktree_path)
563
+ linked_parts: list[str] = []
564
+ if linked:
565
+ linked_parts.append(f"linked {', '.join(linked)}")
566
+ if linked_files:
567
+ linked_parts.append(f"linked-files {', '.join(linked_files)}")
568
+ if snapshot_files:
569
+ linked_parts.append(f"snapshot {', '.join(snapshot_files)}")
570
+ linked_suffix = ("; " + "; ".join(linked_parts)) if linked_parts else ""
431
571
 
432
572
  try:
433
573
  worktree_registry.reserve(
@@ -5,8 +5,6 @@ from .paths import utc_now
5
5
  from .pricing import (
6
6
  claude_billable_equivalent,
7
7
  claude_cost_usd,
8
- codex_cost_usd,
9
- gemini_cost_usd,
10
8
  )
11
9
 
12
10
 
@@ -15,8 +15,6 @@ def claude_session_totals(jsonl_path: Path) -> dict:
15
15
  model: str | None = None
16
16
  first_ts: str | None = None
17
17
  last_ts: str | None = None
18
- started_at: str | None = None
19
- ended_at: str | None = None
20
18
  for rec in iter_jsonl(jsonl_path):
21
19
  if agent_name is None and rec.get("agentName"):
22
20
  agent_name = rec["agentName"]