okstra 0.98.1 → 0.99.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 (41) hide show
  1. package/docs/for-ai/README.md +55 -0
  2. package/docs/for-ai/skills/okstra-brief.md +240 -0
  3. package/docs/for-ai/skills/okstra-container.md +161 -0
  4. package/docs/for-ai/skills/okstra-inspect.md +260 -0
  5. package/docs/for-ai/skills/okstra-memory.md +127 -0
  6. package/docs/for-ai/skills/okstra-run.md +229 -0
  7. package/docs/for-ai/skills/okstra-schedule.md +321 -0
  8. package/docs/for-ai/skills/okstra-setup.md +140 -0
  9. package/docs/kr/architecture.md +5 -5
  10. package/docs/superpowers/plans/2026-06-23-phase-batch-cleanup-enforcement.md +409 -0
  11. package/package.json +1 -1
  12. package/runtime/BUILD.json +2 -2
  13. package/runtime/agents/workers/claude-worker.md +1 -1
  14. package/runtime/bin/okstra-team-reconcile.sh +7 -7
  15. package/runtime/prompts/coding-preflight/overview.md +1 -1
  16. package/runtime/prompts/lead/context-loader.md +1 -1
  17. package/runtime/prompts/lead/convergence.md +3 -4
  18. package/runtime/prompts/lead/okstra-lead-contract.md +26 -24
  19. package/runtime/prompts/lead/report-writer.md +6 -7
  20. package/runtime/prompts/lead/team-contract.md +4 -4
  21. package/runtime/prompts/profiles/_common-contract.md +16 -16
  22. package/runtime/prompts/profiles/forbidden-actions.json +1 -1
  23. package/runtime/prompts/profiles/release-handoff.md +2 -2
  24. package/runtime/python/okstra_ctl/paths.py +1 -5
  25. package/runtime/python/okstra_ctl/render.py +38 -49
  26. package/runtime/python/okstra_ctl/run.py +1 -1
  27. package/runtime/python/okstra_ctl/session.py +1 -1
  28. package/runtime/python/okstra_ctl/team_reconcile.py +10 -12
  29. package/runtime/python/okstra_ctl/worktree.py +2 -3
  30. package/runtime/python/okstra_ctl/worktree_registry.py +3 -3
  31. package/runtime/python/okstra_project/dirs.py +31 -4
  32. package/runtime/python/okstra_token_usage/collect.py +7 -5
  33. package/runtime/skills/okstra-brief/SKILL.md +24 -13
  34. package/runtime/skills/okstra-run/SKILL.md +3 -3
  35. package/runtime/validators/lib/fixtures.sh +8 -7
  36. package/runtime/validators/validate-brief.py +36 -2
  37. package/runtime/validators/validate-run.py +18 -15
  38. package/runtime/validators/validate_session_conformance.py +45 -6
  39. package/src/commands/lifecycle/config.mjs +2 -4
  40. package/src/commands/memory/memory.mjs +2 -3
  41. 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", [])
@@ -393,9 +393,12 @@ Step 0):
393
393
  okstra task-list
394
394
  ```
395
395
 
396
- Parse the JSON `tasks[]` array; collect the **distinct** `taskGroup` values
397
- ordered by `updatedAt` descending. Take the **2 most-recent** distinct
398
- groups as recommendations.
396
+ Parse the JSON `tasks[]` array. The `taskGroup` field is a **raw display
397
+ name, not a slug** — `slugify` each value (lowercase + non-alphanumeric
398
+ `-`) and **dedupe by slug** so case/format variants collapse (e.g.
399
+ `uploadFont` and `uploadfont` are one group, not two). Order the distinct
400
+ slugs by their most-recent `updatedAt`. Take the **2 most-recent** distinct
401
+ slugs as recommendations and show them in slug form.
399
402
 
400
403
  `AskUserQuestion` (single-select):
401
404
 
@@ -413,8 +416,14 @@ picker and ask one free-text question:
413
416
  `"Task group (e.g. backend-api, INV-1234, refactor)"` → `task_group`.
414
417
 
415
418
  Slug rule: same as `okstra-run` Step 3 — slugify, must have at least one
416
- alphanumeric character. Note that an existing group's value is already a
417
- slug; a `직접 입력` value is slugified before use.
419
+ alphanumeric character. **Always slugify the chosen value**, whether it came
420
+ from an existing-group selection or `직접 입력` the `taskGroup` returned by
421
+ `okstra task-list` is a raw display name, not a slug, so picking one verbatim
422
+ would create a non-standard directory (e.g. `briefs/uploadFont/`). The brief's
423
+ on-disk directory segment (`briefs/<task-group>/`) and the frontmatter
424
+ `task-group` value are both the slugified form, and `validate-brief.py`
425
+ (Step 6.6) rejects any brief whose directory segment differs from
426
+ `slugify(frontmatter task-group)`.
418
427
 
419
428
  If `task_group` was already collected by Step 1b sub-step 0 (tracker
420
429
  preflight), reuse that value silently — do NOT ask again.
@@ -752,7 +761,7 @@ Use the same template per brief file. In tracker mode producing a parent
752
761
  plus N children, you write **N+1 files** (each carries only its own Source
753
762
  Material).
754
763
 
755
- The installed template `~/.okstra/templates/reports/brief.template.md` is the byte-for-byte SSOT for the brief shape — frontmatter keys, top-header blockquote, section ordering, and inline `<!-- author guidance -->` HTML comments. Render that file with the per-brief values substituted; leave any section's body as `_(none)_` rather than fabricating content. Preserve the template's HTML comments verbatim (they are part of the contract) but do NOT promote them to body prose.
764
+ The installed template `~/.okstra/templates/reports/brief.template.md` is the byte-for-byte SSOT for the brief shape — frontmatter keys, top-header blockquote, and section ordering. The template's inline `<!-- author guidance -->` HTML comments are fill-in scaffolding for the author, NOT part of the artifact: render the file with per-brief values substituted and **strip every `<!-- ... -->` comment** so the final brief carries none. Leave any section's body as `_(none)_` rather than fabricating content.
756
765
 
757
766
  **Variant note:** The template file is the canonical reporter-input shape. For the codebase-scan variant (`scope: codebase` in frontmatter), OMIT the `## Source Material` and `## Problem / Symptom` headings entirely — do NOT include them with `_(none)_` bodies. Instead, KEEP the `## Scan Scope` and `## Priority Lenses` sections (already present in the template file, marked with HTML comments). For the error-feedback variant (`scope` omitted ⇒ `reporter-input`), use the reporter-input shape: KEEP `## Source Material` (the chosen cluster's anonymized error records) and `## Problem / Symptom`, and OMIT the codebase-only `## Scan Scope` / `## Priority Lenses` sections. The remaining sections (Context / Desired Outcome / Constraints / Related Artifacts / Open Questions / Reporter Confirmations / Augmentation) apply to all variants.
758
767
 
@@ -1010,15 +1019,17 @@ started.
1010
1019
  Augmentation / interpretation goes only into the `Augmentation` section
1011
1020
  or a `> augmented:` blockquote inside the required section.
1012
1021
  - **No template author-guidance in the artifact body.** The Step 5
1013
- template above carries author guidance in HTML comments
1014
- (`<!-- ... -->`); preserve those comments when writing the brief but do
1015
- NOT promote them to body prose. Section headings stay clean — never copy
1016
- parenthetical reviewer notes onto the heading itself (e.g. emit
1022
+ template carries author guidance in HTML comments (`<!-- ... -->`);
1023
+ **strip every such comment when writing the brief** the final artifact
1024
+ contains zero `<!-- ... -->` blocks. (The validator now ignores comment
1025
+ regions, but a clean artifact is the contract, and it is what the
1026
+ passing reference briefs look like.) Section headings stay clean — never
1027
+ copy parenthetical reviewer notes onto the heading itself (e.g. emit
1017
1028
  `## Source Material`, not `## Source Material (verbatim — do not modify)`).
1018
1029
  Placeholder prose inside angle brackets (`<Background / scope / why
1019
- now…>`) is intentional and is meant to be replaced with the reporter's
1020
- actual content; do not strip the angle-bracket placeholders, replace
1021
- them.
1030
+ now…>`) is NOT a comment — it is intentional scaffolding meant to be
1031
+ replaced with the reporter's actual content; replace those placeholders,
1032
+ do not leave them in.
1022
1033
  - If the tracker MCP is not connected, do not guess — ask the user to paste
1023
1034
  the body or skip the source.
1024
1035
  - If a URL fetch fails or hits an auth wall, do not guess — ask for a
@@ -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 = [
@@ -6,7 +6,8 @@ Checks performed per brief file:
6
6
  1. YAML frontmatter exists on line 1 with required keys.
7
7
  2. brief-id matches the filename stem.
8
8
  3. depth equals the number of `sub/` segments in the path (relative to the
9
- `briefs/` root).
9
+ `briefs/` root), and the task-group directory segment equals the
10
+ slugified frontmatter `task-group` (lowercase, non-alphanumeric → `-`).
10
11
  4. Every Open Questions row starts with one of the five signal prefixes
11
12
  (general | terminology | intent-check | conversion-block | adr-candidate).
12
13
  `adr-candidate:` targets okstra-internal
@@ -56,6 +57,7 @@ from okstra_ctl.improvement_lenses import (
56
57
  MAX_PRIORITY_LENSES,
57
58
  MIN_PRIORITY_LENSES,
58
59
  )
60
+ from okstra_ctl.ids import slugify_task_segment
59
61
 
60
62
  REQUIRED_FRONTMATTER_KEYS = {
61
63
  "type",
@@ -89,6 +91,23 @@ REPORTER_CONFIRMATION_VALUES = {"complete", "partial", "pending", "skipped"}
89
91
 
90
92
  SCOPE_VALUES = {"reporter-input", "codebase"}
91
93
 
94
+ _HTML_COMMENT_RE = re.compile(r"<!--.*?-->", re.DOTALL)
95
+
96
+
97
+ def strip_html_comments(text: str) -> str:
98
+ """Remove `<!-- ... -->` blocks before parsing.
99
+
100
+ The brief template carries author-guidance comments whose example rows
101
+ (`intent-check: <…>`, `conversion-block: <…>`, `terminology-mapping: <…>`)
102
+ are NOT real entries. Without this, every `- ` collector below
103
+ (`open_questions_rows`, `augmentation_entries`, `meaningful_bullets`) would
104
+ count them as data and raise false `reporter-confirmations` / `terminology`
105
+ failures. Comments are never brief content, so stripping them once here is
106
+ the single root fix. Frontmatter uses `#` comments only, so this is a no-op
107
+ for the YAML block.
108
+ """
109
+ return _HTML_COMMENT_RE.sub("", text)
110
+
92
111
 
93
112
  def strip_yaml_comment(line: str) -> str:
94
113
  """Drop a YAML inline comment, keeping `#` that is part of a value.
@@ -268,7 +287,7 @@ def check_reporter_confirmations(
268
287
 
269
288
 
270
289
  def validate_brief(path: Path, briefs_root: Path) -> list[str]:
271
- text = path.read_text(encoding="utf-8")
290
+ text = strip_html_comments(path.read_text(encoding="utf-8"))
272
291
  errors: list[str] = []
273
292
 
274
293
  # 1. frontmatter
@@ -315,8 +334,10 @@ def validate_brief(path: Path, briefs_root: Path) -> list[str]:
315
334
  # 3. depth equals path's `sub/` nesting depth
316
335
  try:
317
336
  rel = path.relative_to(briefs_root)
337
+ under_root = True
318
338
  except ValueError:
319
339
  rel = path
340
+ under_root = False
320
341
  # path components after the task-group dir: any number of `sub` segments + filename
321
342
  parts = list(rel.parts)
322
343
  if len(parts) >= 2:
@@ -332,6 +353,19 @@ def validate_brief(path: Path, briefs_root: Path) -> list[str]:
332
353
  f"frontmatter says depth={fm.get('depth')!r}"
333
354
  )
334
355
 
356
+ # 3b. task-group directory segment must equal the slugified frontmatter value.
357
+ # Catches case/format drift (e.g. dir `uploadFont` vs slug `uploadfont`) that
358
+ # case-insensitive filesystems hide but case-sensitive CI breaks on.
359
+ if under_root and len(parts) >= 2:
360
+ expected_segment = slugify_task_segment(fm.get("task-group", ""))
361
+ if expected_segment and parts[0] != expected_segment:
362
+ errors.append(
363
+ f"task-group directory segment {parts[0]!r} does not match the "
364
+ f"slugified frontmatter task-group {expected_segment!r} "
365
+ f"(frontmatter task-group={fm.get('task-group')!r}); rename the "
366
+ f"directory or fix the frontmatter so both use the slug"
367
+ )
368
+
335
369
  # 4. Open Questions prefixes
336
370
  oq_rows = open_questions_rows(text)
337
371
  intent_check_rows: list[str] = []
@@ -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:
@@ -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");