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.
- package/docs/for-ai/README.md +55 -0
- package/docs/for-ai/skills/okstra-brief.md +240 -0
- package/docs/for-ai/skills/okstra-container.md +161 -0
- package/docs/for-ai/skills/okstra-inspect.md +260 -0
- package/docs/for-ai/skills/okstra-memory.md +127 -0
- package/docs/for-ai/skills/okstra-run.md +229 -0
- package/docs/for-ai/skills/okstra-schedule.md +321 -0
- package/docs/for-ai/skills/okstra-setup.md +140 -0
- package/docs/kr/architecture.md +5 -5
- package/docs/superpowers/plans/2026-06-23-phase-batch-cleanup-enforcement.md +409 -0
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/agents/workers/claude-worker.md +1 -1
- package/runtime/bin/okstra-team-reconcile.sh +7 -7
- package/runtime/prompts/coding-preflight/overview.md +1 -1
- package/runtime/prompts/lead/context-loader.md +1 -1
- package/runtime/prompts/lead/convergence.md +3 -4
- package/runtime/prompts/lead/okstra-lead-contract.md +26 -24
- package/runtime/prompts/lead/report-writer.md +6 -7
- package/runtime/prompts/lead/team-contract.md +4 -4
- package/runtime/prompts/profiles/_common-contract.md +16 -16
- package/runtime/prompts/profiles/forbidden-actions.json +1 -1
- package/runtime/prompts/profiles/release-handoff.md +2 -2
- package/runtime/python/okstra_ctl/paths.py +1 -5
- package/runtime/python/okstra_ctl/render.py +38 -49
- package/runtime/python/okstra_ctl/run.py +1 -1
- package/runtime/python/okstra_ctl/session.py +1 -1
- package/runtime/python/okstra_ctl/team_reconcile.py +10 -12
- package/runtime/python/okstra_ctl/worktree.py +2 -3
- package/runtime/python/okstra_ctl/worktree_registry.py +3 -3
- package/runtime/python/okstra_project/dirs.py +31 -4
- package/runtime/python/okstra_token_usage/collect.py +7 -5
- package/runtime/skills/okstra-brief/SKILL.md +24 -13
- package/runtime/skills/okstra-run/SKILL.md +3 -3
- package/runtime/validators/lib/fixtures.sh +8 -7
- package/runtime/validators/validate-brief.py +36 -2
- package/runtime/validators/validate-run.py +18 -15
- package/runtime/validators/validate_session_conformance.py +45 -6
- package/src/commands/lifecycle/config.mjs +2 -4
- package/src/commands/memory/memory.mjs +2 -3
- 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
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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
|
-
|
|
18
|
-
|
|
19
|
-
the
|
|
20
|
-
|
|
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
|
|
83
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
48
|
-
|
|
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
|
-
#
|
|
521
|
-
#
|
|
522
|
-
#
|
|
523
|
-
#
|
|
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
|
|
397
|
-
|
|
398
|
-
|
|
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.
|
|
417
|
-
|
|
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
|
|
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
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
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
|
|
1020
|
-
actual content;
|
|
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
|
|
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. (추천) 이대로
|
|
219
|
-
team 을
|
|
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.
|
|
292
|
-
#
|
|
293
|
-
#
|
|
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":
|
|
297
|
-
"status": "
|
|
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
|
|
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
|
|
361
|
-
|
|
362
|
-
|
|
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("
|
|
434
|
+
elif not isinstance(team_create, dict) or not str(team_create.get("status", "")).strip():
|
|
435
435
|
failures.append(
|
|
436
|
-
"team-state.teamCreate
|
|
437
|
-
"been dispatched (status in completed/timeout/error/in-progress). "
|
|
438
|
-
"
|
|
439
|
-
|
|
440
|
-
"
|
|
441
|
-
"
|
|
442
|
-
|
|
443
|
-
'
|
|
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
|
|
448
|
+
if tc_status != "implicit":
|
|
448
449
|
failures.append(
|
|
449
|
-
"team-state.teamCreate.status must be `
|
|
450
|
-
|
|
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
|
-
"
|
|
391
|
-
"
|
|
392
|
-
"
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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");
|