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.
- package/docs/kr/architecture.md +1 -1
- package/docs/kr/performance-improvement-plan-v2.md +330 -0
- package/docs/kr/performance-improvement-plan.md +125 -0
- package/docs/project-structure-overview.md +386 -0
- package/docs/superpowers/plans/2026-05-14-convergence-queue-pruning.md +1568 -0
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/agents/SKILL.md +7 -1
- package/runtime/agents/workers/codex-worker.md +6 -4
- package/runtime/agents/workers/gemini-worker.md +6 -4
- package/runtime/agents/workers/report-writer-worker.md +4 -0
- package/runtime/bin/okstra-codex-exec.sh +36 -6
- package/runtime/bin/okstra-gemini-exec.sh +6 -8
- package/runtime/prompts/profiles/final-verification.md +8 -2
- package/runtime/prompts/profiles/implementation-planning.md +1 -1
- package/runtime/prompts/profiles/release-handoff.md +26 -28
- package/runtime/prompts/profiles/requirements-discovery.md +1 -1
- package/runtime/python/okstra_ctl/render.py +78 -4
- package/runtime/python/okstra_ctl/run.py +0 -6
- package/runtime/python/okstra_ctl/run_context.py +5 -0
- package/runtime/python/okstra_ctl/workflow.py +8 -7
- package/runtime/python/okstra_ctl/worktree.py +155 -15
- package/runtime/python/okstra_token_usage/blocks.py +0 -2
- package/runtime/python/okstra_token_usage/claude.py +0 -2
- package/runtime/skills/okstra-brief/SKILL.md +523 -0
- package/runtime/skills/okstra-convergence/SKILL.md +149 -37
- package/runtime/skills/okstra-report-writer/SKILL.md +8 -6
- package/runtime/templates/prd/brief.template.md +12 -0
- package/runtime/templates/project-docs/task-index.template.md +12 -0
- package/runtime/templates/reports/error-analysis-input.template.md +12 -0
- package/runtime/templates/reports/final-report.template.md +39 -12
- package/runtime/templates/reports/final-verification-input.template.md +22 -0
- package/runtime/templates/reports/implementation-input.template.md +12 -0
- package/runtime/templates/reports/implementation-planning-input.template.md +12 -0
- package/runtime/templates/reports/quick-input.template.md +12 -0
- package/runtime/templates/reports/release-handoff-input.template.md +23 -10
- package/runtime/templates/reports/schedule.template.md +12 -0
- package/runtime/templates/reports/settings.template.json +83 -30
- 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
|
|
128
|
-
" - asking the user (via `AskUserQuestion` / interactive prompt) which delivery action to take: `
|
|
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
|
|
131
|
-
" -
|
|
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
|
|
138
|
-
" - any source-code edit, refactor, or scope expansion beyond what is strictly needed to author
|
|
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 `
|
|
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
|
|
194
|
-
"""Read
|
|
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
|
-
|
|
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(
|
|
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
|
|
222
|
-
"""
|
|
223
|
-
|
|
224
|
-
|
|
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(
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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(
|
|
@@ -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"]
|