okstra 0.98.2 → 0.99.1

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 (31) hide show
  1. package/docs/kr/architecture.md +5 -5
  2. package/docs/superpowers/plans/2026-06-23-phase-batch-cleanup-enforcement.md +409 -0
  3. package/package.json +1 -1
  4. package/runtime/BUILD.json +2 -2
  5. package/runtime/agents/workers/claude-worker.md +1 -1
  6. package/runtime/bin/okstra-team-reconcile.sh +7 -7
  7. package/runtime/prompts/coding-preflight/overview.md +1 -1
  8. package/runtime/prompts/lead/context-loader.md +1 -1
  9. package/runtime/prompts/lead/convergence.md +3 -4
  10. package/runtime/prompts/lead/okstra-lead-contract.md +26 -24
  11. package/runtime/prompts/lead/report-writer.md +6 -7
  12. package/runtime/prompts/lead/team-contract.md +4 -4
  13. package/runtime/prompts/profiles/_common-contract.md +16 -16
  14. package/runtime/prompts/profiles/forbidden-actions.json +1 -1
  15. package/runtime/prompts/profiles/release-handoff.md +2 -2
  16. package/runtime/python/okstra_ctl/paths.py +1 -5
  17. package/runtime/python/okstra_ctl/render.py +38 -49
  18. package/runtime/python/okstra_ctl/run.py +1 -1
  19. package/runtime/python/okstra_ctl/session.py +1 -1
  20. package/runtime/python/okstra_ctl/team_reconcile.py +10 -12
  21. package/runtime/python/okstra_ctl/worktree.py +2 -3
  22. package/runtime/python/okstra_ctl/worktree_registry.py +3 -3
  23. package/runtime/python/okstra_project/dirs.py +31 -4
  24. package/runtime/python/okstra_token_usage/collect.py +7 -5
  25. package/runtime/skills/okstra-run/SKILL.md +3 -3
  26. package/runtime/validators/lib/fixtures.sh +8 -7
  27. package/runtime/validators/validate-run.py +18 -15
  28. package/runtime/validators/validate_session_conformance.py +62 -10
  29. package/src/commands/lifecycle/config.mjs +2 -4
  30. package/src/commands/memory/memory.mjs +2 -3
  31. package/src/lib/paths.mjs +28 -2
@@ -2,22 +2,20 @@
2
2
 
3
3
  A Claude Code team member clears its own ``isActive`` flag in
4
4
  ``~/.claude/teams/<team>/config.json`` when its ``Agent()`` dispatch returns, so
5
- by Phase 7 every worker is normally already inactive and ``TeamDelete()``
6
- succeeds immediately. The one failure mode is a member whose tmux pane died
7
- WITHOUT clearing the flag (killed mid-turn): it stays ``isActive: true`` forever,
8
- and ``TeamDelete`` then refuses the whole team with an "active members" error
9
- that re-sending ``shutdown_request`` cannot clear — the addressee is already
10
- gone.
5
+ by Phase 7 every worker is normally already inactive. The one failure mode is a
6
+ member whose tmux pane died WITHOUT clearing the flag (killed mid-turn): it stays
7
+ ``isActive: true`` forever and keeps showing as a live member that re-sending
8
+ ``shutdown_request`` cannot clear the addressee is already gone.
11
9
 
12
10
  This module reconciles exactly that case: a member with ``isActive`` truthy
13
11
  whose recorded tmux pane is no longer live is flipped to inactive. It NEVER
14
12
  touches a member whose pane is still live, the lead, or a member with no
15
13
  recorded pane — those are left for graceful shutdown.
16
14
 
17
- Note: the active-member flag is the harness's source of truth, but whether
18
- ``TeamDelete`` re-reads ``config.json`` at delete time (vs. caching the roster in
19
- the owning session) is a harness behaviour that can only be confirmed inside a
20
- real run — see _common-contract.md "Run-end team teardown".
15
+ CC v2.1.178 removed ``TeamDelete``: the implicit team ends with the session and
16
+ run-end teardown only dismisses teammates via ``shutdown_request``. Reconcile
17
+ keeps the roster honest so a dismissed-but-dead member does not linger as a
18
+ live pill — see _common-contract.md "Run-end teammate teardown".
21
19
  """
22
20
  from __future__ import annotations
23
21
 
@@ -79,8 +77,8 @@ def _config_path(team_arg: str) -> Path:
79
77
 
80
78
 
81
79
  def _atomic_write(path: Path, text: str) -> None:
82
- """Replace ``path`` atomically — config.json is load-bearing for the next
83
- TeamDelete, so a partial write on crash must never be observable."""
80
+ """Replace ``path`` atomically — config.json is the harness's roster source
81
+ of truth, so a partial write on crash must never be observable."""
84
82
  fd, tmp = tempfile.mkstemp(dir=str(path.parent), prefix=".reconcile-")
85
83
  try:
86
84
  with os.fdopen(fd, "w") as handle:
@@ -41,7 +41,7 @@ from dataclasses import dataclass
41
41
  from pathlib import Path
42
42
  from typing import Optional
43
43
 
44
- from okstra_project.dirs import project_json_path
44
+ from okstra_project.dirs import okstra_home, project_json_path
45
45
 
46
46
  from .ids import _safe_fs_segment
47
47
  from . import worktree_registry
@@ -642,8 +642,7 @@ def compute_worktree_path(
642
642
  `~/.okstra`."""
643
643
  if stage_number is not None and group_id is not None:
644
644
  raise ValueError("stage_number and group_id are mutually exclusive")
645
- okstra_home = os.environ.get("OKSTRA_HOME", "").strip()
646
- base = Path(okstra_home) if okstra_home else (Path.home() / ".okstra")
645
+ base = okstra_home()
647
646
  path = (
648
647
  base / "worktrees"
649
648
  / _safe_segment(project_id)
@@ -32,15 +32,15 @@ from dataclasses import dataclass
32
32
  from pathlib import Path
33
33
  from typing import Optional
34
34
 
35
+ from okstra_project.dirs import okstra_home
36
+
35
37
 
36
38
  REGISTRY_FILENAME = "registry.json"
37
39
  LOCK_FILENAME = "registry.lock"
38
40
 
39
41
 
40
42
  def _okstra_worktrees_dir() -> Path:
41
- home_env = os.environ.get("OKSTRA_HOME", "").strip()
42
- base = Path(home_env) if home_env else (Path.home() / ".okstra")
43
- return base / "worktrees"
43
+ return okstra_home() / "worktrees"
44
44
 
45
45
 
46
46
  def task_key(
@@ -15,6 +15,8 @@ DRY 위반의 비용: 이전에는 동일 path 문자열이 50+ Python·Shell·m
15
15
  from __future__ import annotations
16
16
 
17
17
  import os
18
+ import sys
19
+ import tempfile
18
20
  from pathlib import Path
19
21
 
20
22
  OKSTRA_DIR_NAME = ".okstra"
@@ -40,12 +42,37 @@ TASK_CATALOG_RELATIVE = DISCOVERY_RELATIVE / "task-catalog.json"
40
42
  LATEST_TASK_RELATIVE = DISCOVERY_RELATIVE / "latest-task.json"
41
43
 
42
44
 
45
+ def _is_stale_tmp_home(path: str) -> bool:
46
+ """삭제된 pytest 격리 OKSTRA_HOME 누수 판별. tests/conftest.py 가 격리 홈을
47
+ ``<tmp_path>/.okstra-isolated``(시스템 임시 하위)로 만드는데, 그 디렉터리가
48
+ 사라진 채 env 만 남은 경우만 폴백 대상이다. 실제/커스텀 홈이나 곧 생성될
49
+ 임시 홈(``.okstra`` 등)은 이름이 달라 걸리지 않는다(테스트 오폴백 방지)."""
50
+ if Path(path).name != ".okstra-isolated":
51
+ return False
52
+ roots = [
53
+ tempfile.gettempdir(),
54
+ "/tmp", "/private/tmp", "/var/folders", "/private/var/folders",
55
+ ]
56
+ return any(path == r or path.startswith(r.rstrip("/") + "/")
57
+ for r in roots if r)
58
+
59
+
43
60
  def okstra_home() -> Path:
44
- """`~/.okstra` 절대 path. 테스트/설치 환경에서 `OKSTRA_HOME` env 로 override."""
61
+ """`~/.okstra` 절대 path. 테스트/설치 환경에서 `OKSTRA_HOME` env 로 override.
62
+
63
+ OKSTRA_HOME 이 존재하지 않는 시스템 임시 경로(삭제된 pytest 격리 홈 등
64
+ 누수된 값)를 가리키면 stderr 경고 후 기본 `~/.okstra` 로 폴백한다."""
45
65
  override = os.environ.get("OKSTRA_HOME", "").strip()
46
- if override:
47
- return Path(override)
48
- return Path.home() / ".okstra"
66
+ if not override:
67
+ return Path.home() / ".okstra"
68
+ home = Path(override).expanduser()
69
+ if not home.exists() and _is_stale_tmp_home(str(home)):
70
+ sys.stderr.write(
71
+ f"⚠ OKSTRA_HOME={override} 가 존재하지 않는 임시 경로입니다 "
72
+ "(누수된 테스트 격리 값으로 보임) → ~/.okstra 로 폴백합니다.\n"
73
+ )
74
+ return Path.home() / ".okstra"
75
+ return home
49
76
 
50
77
 
51
78
  def okstra_root(project_root: Path) -> Path:
@@ -517,12 +517,14 @@ def collect(team_state_path: Path, project_root: Path | None = None, *,
517
517
  unattributed_sessions.append(sid)
518
518
  unattributed_totals.append(totals)
519
519
 
520
- # no-team run (teamCreate skipped/concurrent-run, or error fallback)
521
- # worker 세션에 team 태그가 없어 needle 탐색이 비므로, agentName 기반
522
- # 발견으로 보강한다. run 윈도우 세션(in-window 이벤트 없음 = startedAt
523
- # 부재)은 같은 agentName run 세션이므로 버린다.
520
+ # implicit-team run (CC v2.1.178+: no TeamCreate, status "implicit") or
521
+ # legacy no-team run (skipped/concurrent-run, error fallback) the implicit
522
+ # team tags worker jsonls with the harness team name (`session-<leadSid>`),
523
+ # NOT okstra's `teamName` label, so the needle scan above only finds the lead.
524
+ # agentName 기반 발견으로 worker 세션을 보강한다. run 윈도우 밖 세션(in-window
525
+ # 이벤트 없음 = startedAt 부재)은 같은 agentName 의 타 run 세션이므로 버린다.
524
526
  team_create_status = str((state.get("teamCreate") or {}).get("status", "")).strip()
525
- if team_create_status in ("skipped", "error"):
527
+ if team_create_status in ("implicit", "skipped", "error"):
526
528
  worker_prefix_pool = [
527
529
  prefix
528
530
  for w in state.get("workers", [])
@@ -209,14 +209,14 @@ When the user picks a waiver, append `--qa-waiver "<stageKey>:<reason>"` to the
209
209
 
210
210
  `render-bundle` stdout 에 `okstra concurrent-run stages: <stages>` 라벨 라인이
211
211
  있으면(같은 task-key 의 다른 implementation run 이 `<stages>` 를 점유 중), launch
212
- 프롬프트는 이미 "Concurrent-run: no-team background" 게이트로 렌더돼 있다. 이 라인이
212
+ 프롬프트는 이미 "Concurrent-run marker" 게이트로 렌더돼 있다. 이 라인이
213
213
  없으면 동시-run 이 아니므로 이 분기를 건너뛴다. 라인이 있으면 dispatch 전에
214
214
  사용자에게 3-옵션 recommendation picker 를 제시한다 (run-prompt recommendation 규칙:
215
215
  1–2 추천 + 직접 입력; 이 picker 는 스킬이 author 하는 것이라 wizard `options[]`
216
216
  제약과 무관):
217
217
 
218
- 1. (추천) 이대로 no-team background 로 진행 — 이미 렌더된 bundle 을 그대로 사용한다.
219
- team 을 만들지 않아 `~/.claude/teams/` race 회피한다(Teams split-pane 관찰성만 포기).
218
+ 1. (추천) 이대로 진행 — 이미 렌더된 bundle 을 그대로 사용한다. 각 세션은 자기
219
+ implicit team 을 쓰므로 동시 run 끼리 team 충돌이 없고, split-pane 정상 동작한다.
220
220
  2. 대기 — 지금 dispatch 를 보류한다. stage worktree·run-context 는 보존되므로,
221
221
  점유 중인 다른 run 종료 후 같은 stage 를 resume 으로 재개하면 그때는 정상 team
222
222
  경로다. resume 명령(`okstra-inspect` history → resume)을 사용자에게 출력한다.
@@ -288,13 +288,13 @@ if isinstance(lead, dict):
288
288
  lead["sessionId"] = "fixture-lead-session-0001"
289
289
  team_state["workflowState"] = "worker-results-collected"
290
290
 
291
- # validate-run.py requires team-state.teamCreate.attempted=true with a
292
- # status of ok|error once any worker has been dispatched (see
293
- # validators/validate-run.py:334-337). Mirror that here so the fixture
294
- # represents a valid post-Phase-3 state.
291
+ # validate-run.py requires team-state.teamCreate.status == "implicit" once any
292
+ # worker has been dispatched (CC v2.1.178 removed TeamCreate; every session has
293
+ # an implicit team). Mirror that here so the fixture is a valid post-Phase-3 state.
295
294
  team_state["teamCreate"] = {
296
- "attempted": True,
297
- "status": "ok",
295
+ "attempted": False,
296
+ "status": "implicit",
297
+ "splitPane": False,
298
298
  }
299
299
 
300
300
  # Phase 7 token-usage collection is normally produced by okstra-token-usage.py.
@@ -461,7 +461,7 @@ if lead_sid:
461
461
  "PROGRESS: phase-1-intake reading task bundle",
462
462
  "PROGRESS: phase-1-intake complete",
463
463
  f"PROGRESS: phase-2-prompts preparing {len(team_state.get('workers') or [])} worker prompts",
464
- "PROGRESS: phase-3-team-create attempting TeamCreate",
464
+ "PROGRESS: phase-3-team-create using implicit team (split-pane=off)",
465
465
  ]
466
466
  for worker in team_state.get("workers", []):
467
467
  if not isinstance(worker, dict):
@@ -478,6 +478,7 @@ if lead_sid:
478
478
  progress_lines.append(
479
479
  f"PROGRESS: phase-5-collect worker={worker_id}-worker status=completed"
480
480
  )
481
+ progress_lines.append("PROGRESS: phase-batch-cleanup panes=1 teammates=1")
481
482
  progress_lines.append("PROGRESS: phase-6-synthesis dispatching report-writer-worker")
482
483
  progress_lines.append("PROGRESS: phase-7-persist updating manifests")
483
484
  records = [
@@ -357,9 +357,9 @@ def _is_legal_concurrent_run_skip(
357
357
  team_create: object, concurrent_run_authorized: bool
358
358
  ) -> bool:
359
359
  """prepare 가 run-manifest 에 동시-run 을 기록한 run 에서만, 렌더 게이트
360
- ("Concurrent-run: no-team background")가 지시한 teamCreate skipped 형태를
361
- legal 터미널 상태로 인정한다. lead 의 team-state 자기 선언만으로는(앵커
362
- 없이) 열리지 않는다 — 선언이 아닌 prepare-측 사실이 강제 근거다."""
360
+ ("Concurrent-run marker")가 지시한 teamCreate skipped 형태를 legal 터미널
361
+ 상태로 인정한다. lead 의 team-state 자기 선언만으로는(앵커 없이) 열리지
362
+ 않는다 — 선언이 아닌 prepare-측 사실이 강제 근거다."""
363
363
  if not concurrent_run_authorized or not isinstance(team_create, dict):
364
364
  return False
365
365
  return (
@@ -431,23 +431,26 @@ def validate_team_state(
431
431
  team_create = team_state.get("teamCreate")
432
432
  if _is_legal_concurrent_run_skip(team_create, concurrent_run_authorized):
433
433
  pass
434
- elif not isinstance(team_create, dict) or not team_create.get("attempted"):
434
+ elif not isinstance(team_create, dict) or not str(team_create.get("status", "")).strip():
435
435
  failures.append(
436
- "team-state.teamCreate.attempted must be true once any worker has "
437
- "been dispatched (status in completed/timeout/error/in-progress). "
438
- "Phase 3 (TeamCreate) was skipped workers ran in-process without "
439
- "the Teams split-pane surface. See prompts/lead/okstra-lead-contract.md Phase 3. "
440
- "(The no-team concurrent-run path is legal ONLY when the "
441
- "run-manifest carries prepare-recorded `concurrentRun.detected: "
442
- 'true` AND team-state records `teamCreate: { attempted: false, '
443
- 'status: "skipped", reason: "concurrent-run" }`.)'
436
+ "team-state.teamCreate must be recorded with a status once any worker "
437
+ "has been dispatched (status in completed/timeout/error/in-progress). "
438
+ "CC v2.1.178 removed TeamCreate; every session has an implicit team, so "
439
+ 'Phase 3 records `teamCreate: { attempted: false, status: "implicit" }` — '
440
+ "the audit marker that drives Phase 7 token attribution. See "
441
+ "prompts/lead/okstra-lead-contract.md Phase 3. (The concurrent-run path "
442
+ "is legal ONLY when the run-manifest carries prepare-recorded "
443
+ '`concurrentRun.detected: true` AND team-state records '
444
+ '`teamCreate: { attempted: false, status: "skipped", reason: "concurrent-run" }`.)'
444
445
  )
445
446
  else:
446
447
  tc_status = str(team_create.get("status", "")).strip()
447
- if tc_status not in {"ok", "error"}:
448
+ if tc_status != "implicit":
448
449
  failures.append(
449
- "team-state.teamCreate.status must be `ok` or `error` once "
450
- f"workers have been dispatched (found: `{tc_status}`)."
450
+ "team-state.teamCreate.status must be `implicit` once workers have "
451
+ "been dispatched (the only other legal value is `skipped` with "
452
+ "reason `concurrent-run` in a prepare-authorized concurrent run). "
453
+ f"Found: `{tc_status}`."
451
454
  )
452
455
 
453
456
  by_role: dict[str, dict] = {}
@@ -348,6 +348,38 @@ def _check_worker_checkpoint_lines(
348
348
  )
349
349
 
350
350
 
351
+ def _check_batch_cleanup_checkpoints(
352
+ by_phase: dict[str, list[tuple[str, str]]],
353
+ convergence_ran: bool,
354
+ report_writer_dispatched: bool,
355
+ errors: list[str],
356
+ ) -> None:
357
+ """phase-batch-cleanup: 각 배치 경계 직전에 이전 배치 pane/teammate 정리가
358
+ 실제로 일어났는지 (prompts/profiles/_common-contract.md 'Phase-start cleanup').
359
+ R1=convergence round 1 직전, R2=report-writer dispatch 직전(수렴이 있었으면
360
+ 마지막 라운드 이후 별도 1회). ISO-8601 ts 는 lexicographic 비교가 곧 시간순."""
361
+ detail = "prompts/profiles/_common-contract.md 'Phase-start cleanup'"
362
+ cleanup_ts = sorted(ts for ts, _line in by_phase.get("phase-batch-cleanup", []))
363
+ conv_ts = sorted(ts for ts, _line in by_phase.get("phase-5.5-convergence", []))
364
+ synth_ts = sorted(ts for ts, _line in by_phase.get("phase-6-synthesis", []))
365
+
366
+ if convergence_ran and conv_ts and not any(ts <= conv_ts[0] for ts in cleanup_ts):
367
+ errors.append(
368
+ "PROGRESS checkpoint missing: no `phase-batch-cleanup` line before the "
369
+ f"first `phase-5.5-convergence` round ({conv_ts[0]}) — the prior analysis "
370
+ f"batch's panes/teammates must be cleared first ({detail})."
371
+ )
372
+
373
+ if report_writer_dispatched and synth_ts:
374
+ lower = conv_ts[-1] if (convergence_ran and conv_ts) else ""
375
+ if not any(lower <= ts <= synth_ts[0] for ts in cleanup_ts):
376
+ errors.append(
377
+ "PROGRESS checkpoint missing: no `phase-batch-cleanup` line before "
378
+ f"`phase-6-synthesis` ({synth_ts[0]}) — the prior batch must be cleared "
379
+ f"before dispatching report-writer ({detail})."
380
+ )
381
+
382
+
351
383
  def _check_progress_checkpoints(
352
384
  evidence: _LeadEvidence,
353
385
  team_state: dict,
@@ -387,24 +419,31 @@ def _check_progress_checkpoints(
387
419
  require(
388
420
  "phase-3-team-create",
389
421
  any_dispatched,
390
- "immediately before the TeamCreate call or, in the authorized no-team "
391
- "concurrent-run path, the `phase-3-team-create skipped (concurrent-run)` "
392
- "variant emitted right after recording teamCreate skipped in team-state",
422
+ "in Phase 3 after recording teamCreate in team-state the "
423
+ "`using implicit team (split-pane=<on|off>)` line, or the "
424
+ "`skipped (concurrent-run)` variant in the concurrent-run path",
393
425
  )
394
426
  _check_worker_checkpoint_lines(by_phase, analysis_workers, errors)
427
+ convergence_ran = _convergence_rounds_ran(run_dir, suffix)
395
428
  require(
396
429
  "phase-5.5-convergence",
397
- _convergence_rounds_ran(run_dir, suffix),
430
+ convergence_ran,
398
431
  "at the start of each convergence round (state artifact records totalRounds >= 1)",
399
432
  )
400
433
  report_writer = next((w for w in workers if _is_report_writer(w)), None)
434
+ report_writer_dispatched = (
435
+ report_writer is not None
436
+ and str(report_writer.get("status", "")).strip() in _DISPATCHED_STATUSES
437
+ )
401
438
  require(
402
439
  "phase-6-synthesis",
403
- report_writer is not None
404
- and str(report_writer.get("status", "")).strip() in _DISPATCHED_STATUSES,
440
+ report_writer_dispatched,
405
441
  "at the start of Phase 6 (report-writer dispatch)",
406
442
  )
407
443
  require("phase-7-persist", True, "at the start of Phase 7")
444
+ _check_batch_cleanup_checkpoints(
445
+ by_phase, convergence_ran, report_writer_dispatched, errors
446
+ )
408
447
 
409
448
 
410
449
  def _parse_iso(ts: str) -> datetime | None:
@@ -469,13 +508,26 @@ def _check_heartbeat_sidecar(path: Path, errors: list[str]) -> None:
469
508
  prev = ts
470
509
 
471
510
 
472
- def _check_heartbeat_sidecars(run_dir: Path, task_type: str, errors: list[str]) -> None:
511
+ def _check_heartbeat_sidecars(
512
+ run_dir: Path, task_type: str, suffix: str | None, errors: list[str]
513
+ ) -> None:
473
514
  """검사 2 — 사이드카 **존재** 는 validate-run.py validate_worker_results_audit 가
474
- 이미 강제하므로, 여기서는 존재하는 사이드카의 heartbeat 내용만 본다."""
515
+ 이미 강제하므로, 여기서는 존재하는 사이드카의 heartbeat 내용만 본다.
516
+
517
+ 비-stage 격리 task-type(requirements-discovery / error-analysis /
518
+ implementation-planning)은 `runs/<task-type>/worker-results/` 를 run 끼리
519
+ 공유한다 — seq 는 파일명에만 있다. seq 를 무시하고 glob 하면 이미 폐기된
520
+ 직전 run 의 audit(heartbeat 위반)까지 검사해 현재 run 을 오탐 실패시킨다.
521
+ 다른 검사(progress / sidecar-read)와 동일하게 현재 run seq(suffix)로 스코핑한다."""
475
522
  worker_results_dir = run_dir / "worker-results"
476
523
  if not worker_results_dir.is_dir():
477
524
  return
478
- for path in sorted(worker_results_dir.glob(f"claude-worker-audit-{task_type}-*.md")):
525
+ pattern = (
526
+ f"claude-worker-audit-{suffix}.md"
527
+ if suffix
528
+ else f"claude-worker-audit-{task_type}-*.md"
529
+ )
530
+ for path in sorted(worker_results_dir.glob(pattern)):
479
531
  _check_heartbeat_sidecar(path, errors)
480
532
 
481
533
 
@@ -534,8 +586,8 @@ def validate_session_conformance(
534
586
  return result
535
587
 
536
588
  run_dir = report_path.parent.parent
537
- _check_heartbeat_sidecars(run_dir, task_type, result.errors)
538
589
  suffix = run_artifact_suffix(team_state_path)
590
+ _check_heartbeat_sidecars(run_dir, task_type, suffix, result.errors)
539
591
 
540
592
  lead_runtime = team_state.get("leadRuntime", "") or "claude-code"
541
593
  dispatch_mode = str(team_state.get("dispatchMode", "")).strip()
@@ -1,7 +1,6 @@
1
1
  import { promises as fs } from "node:fs";
2
- import { homedir } from "node:os";
3
2
  import { dirname, join, resolve, isAbsolute } from "node:path";
4
- import { buildPythonpath, resolvePaths } from "../../lib/paths.mjs";
3
+ import { buildPythonpath, resolvePaths, resolveOkstraHome } from "../../lib/paths.mjs";
5
4
  import { OKSTRA_DIR, projectJsonPath } from "../../lib/okstra-dirs.mjs";
6
5
  import { fileExists, runProcess } from "../../lib/proc.mjs";
7
6
 
@@ -124,8 +123,7 @@ function parseArgs(args) {
124
123
  }
125
124
 
126
125
  function okstraHome() {
127
- const override = (process.env.OKSTRA_HOME || "").trim();
128
- return override !== "" ? override : join(homedir(), ".okstra");
126
+ return resolveOkstraHome();
129
127
  }
130
128
 
131
129
  function globalConfigPath() {
@@ -2,8 +2,8 @@ import { createInterface } from "node:readline/promises";
2
2
  import { stdin as input, stdout as output } from "node:process";
3
3
  import { randomBytes } from "node:crypto";
4
4
  import { promises as fs } from "node:fs";
5
- import { homedir } from "node:os";
6
5
  import { basename, dirname, join, relative, resolve } from "node:path";
6
+ import { resolveOkstraHome } from "../../lib/paths.mjs";
7
7
 
8
8
  const MEMORY_TYPES = new Set([
9
9
  "context",
@@ -68,8 +68,7 @@ Add options:
68
68
  `;
69
69
 
70
70
  function okstraHome() {
71
- const override = (process.env.OKSTRA_HOME || "").trim();
72
- return override !== "" ? override : join(homedir(), ".okstra");
71
+ return resolveOkstraHome();
73
72
  }
74
73
 
75
74
  function memoryRoot() {
package/src/lib/paths.mjs CHANGED
@@ -1,10 +1,36 @@
1
- import { homedir } from "node:os";
1
+ import { homedir, tmpdir } from "node:os";
2
2
  import { join } from "node:path";
3
3
  import { existsSync } from "node:fs";
4
4
  import { getPackageRoot, getPackageVersion } from "./version.mjs";
5
5
 
6
+ function isStaleTmpHome(p) {
7
+ // tests/conftest.py creates the isolated home as `<tmp_path>/.okstra-isolated`
8
+ // under the system temp dir. Only a deleted one (env leaked, dir gone) heals;
9
+ // a soon-to-be-created `.okstra` temp home or a custom home is left untouched.
10
+ if (!p.endsWith("/.okstra-isolated")) return false;
11
+ const roots = [tmpdir(), "/tmp", "/private/tmp", "/var/folders", "/private/var/folders"];
12
+ return roots.some(
13
+ (r) => r && (p === r || p.startsWith(r.replace(/\/+$/, "") + "/")),
14
+ );
15
+ }
16
+
17
+ // Resolve OKSTRA_HOME, healing a leaked value: when it points at a missing
18
+ // system-temp path (e.g. a deleted pytest-isolated home), warn and fall back
19
+ // to ~/.okstra instead of operating on the dead directory.
20
+ export function resolveOkstraHome() {
21
+ const raw = process.env.OKSTRA_HOME?.trim();
22
+ if (!raw) return join(homedir(), ".okstra");
23
+ if (!existsSync(raw) && isStaleTmpHome(raw)) {
24
+ process.stderr.write(
25
+ `⚠ OKSTRA_HOME=${raw} points to a missing temp dir (leaked test value); falling back to ~/.okstra\n`,
26
+ );
27
+ return join(homedir(), ".okstra");
28
+ }
29
+ return raw;
30
+ }
31
+
6
32
  export async function resolvePaths() {
7
- const home = process.env.OKSTRA_HOME?.trim() || join(homedir(), ".okstra");
33
+ const home = resolveOkstraHome();
8
34
  const pkgRoot = getPackageRoot();
9
35
  const devLink = await readSimpleFile(join(home, "dev-link"));
10
36
  const runtimeRoot = devLink || join(pkgRoot, "runtime");