okstra 0.125.6 → 0.128.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/architecture/storage-model.md +10 -3
- package/docs/architecture.md +14 -2
- package/docs/cli.md +1 -0
- package/docs/project-structure-overview.md +9 -6
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/agents/workers/report-writer-worker.md +1 -1
- package/runtime/bin/okstra-antigravity-exec.sh +8 -2
- package/runtime/bin/okstra-claude-exec.sh +8 -2
- package/runtime/bin/okstra-codex-exec.sh +65 -8
- package/runtime/prompts/lead/adapters/claude-code.md +1 -0
- package/runtime/prompts/lead/convergence.md +8 -1
- package/runtime/prompts/lead/okstra-lead-contract.md +26 -1
- package/runtime/prompts/lead/report-writer.md +1 -1
- package/runtime/prompts/lead/team-contract.md +14 -3
- package/runtime/prompts/profiles/final-verification.md +10 -6
- package/runtime/python/okstra_ctl/analysis_packet.py +7 -2
- package/runtime/python/okstra_ctl/codex_dispatch.py +27 -1
- package/runtime/python/okstra_ctl/design_surfaces.py +30 -14
- package/runtime/python/okstra_ctl/dispatch_core.py +20 -0
- package/runtime/python/okstra_ctl/log_report.py +44 -5
- package/runtime/python/okstra_ctl/path_hints.py +5 -0
- package/runtime/python/okstra_ctl/render.py +22 -0
- package/runtime/python/okstra_ctl/render_final_report.py +71 -5
- package/runtime/python/okstra_ctl/run.py +69 -3
- package/runtime/python/okstra_ctl/schema_excerpt.py +17 -1
- package/runtime/python/okstra_ctl/wizard.py +15 -4
- package/runtime/python/okstra_ctl/work_categories.py +37 -0
- package/runtime/python/okstra_ctl/worker_heartbeat.py +50 -0
- package/runtime/python/okstra_ctl/worker_liveness.py +140 -0
- package/runtime/python/okstra_ctl/worker_prompt_contract.py +223 -0
- package/runtime/python/okstra_ctl/worker_prompt_headers.py +51 -2
- package/runtime/templates/reports/final-report.template.md +9 -9
- package/runtime/templates/worker-prompt-preamble.md +13 -5
- package/runtime/validators/forbidden_actions.py +43 -4
- package/runtime/validators/validate-implementation-plan-stages.py +6 -4
- package/runtime/validators/validate-report-views.py +4 -4
- package/runtime/validators/validate-run.py +61 -6
- package/runtime/validators/validate-schedule.py +6 -4
- package/runtime/validators/validate_fanout.py +4 -3
- package/runtime/validators/validate_improvement_report.py +6 -4
- package/runtime/validators/validate_session_conformance.py +16 -7
- package/src/cli-registry.mjs +7 -0
- package/src/commands/inspect/log-report.mjs +5 -3
- package/src/commands/inspect/worker-liveness.mjs +31 -0
- package/src/commands/lifecycle/preflight.mjs +25 -2
- package/src/lib/helper-scripts.mjs +23 -0
- package/src/lib/python-helper.mjs +1 -1
|
@@ -85,6 +85,7 @@ from okstra_ctl.worktree import (
|
|
|
85
85
|
preview_worktree_decision,
|
|
86
86
|
)
|
|
87
87
|
from okstra_ctl.paths import task_dir, task_runs_dir
|
|
88
|
+
from okstra_ctl.work_categories import resolve_work_category
|
|
88
89
|
from okstra_ctl.run_context import latest_run_inputs
|
|
89
90
|
from okstra_project.dirs import project_json_path
|
|
90
91
|
from okstra_project.state import (
|
|
@@ -3126,6 +3127,17 @@ def _submit_fix_cycle_confirm(state: WizardState, value: str) -> Optional[str]:
|
|
|
3126
3127
|
return f"fix-cycle: {v}"
|
|
3127
3128
|
|
|
3128
3129
|
|
|
3130
|
+
def _preview_work_category(state: WizardState) -> str:
|
|
3131
|
+
"""okstra-run 경로는 --work-category 를 넘기지 않는다. prepare 가 쓰는 것과
|
|
3132
|
+
같은 resolver 를 태워야 미리보기 브랜치명이 실제 생성 브랜치와 일치한다."""
|
|
3133
|
+
return resolve_work_category(
|
|
3134
|
+
"",
|
|
3135
|
+
project_root=Path(state.project_root),
|
|
3136
|
+
task_group=state.task_group,
|
|
3137
|
+
task_id=state.task_id,
|
|
3138
|
+
)
|
|
3139
|
+
|
|
3140
|
+
|
|
3129
3141
|
def _worktree_preview_line(state: WizardState) -> Optional[str]:
|
|
3130
3142
|
"""confirm 직전 요약 블록에 넣을 worktree 미리보기 한 줄.
|
|
3131
3143
|
final-verification 은 stage worktree 를 읽기 전용 재사용하므로 None."""
|
|
@@ -3138,9 +3150,7 @@ def _worktree_preview_line(state: WizardState) -> Optional[str]:
|
|
|
3138
3150
|
project_id=state.project_id,
|
|
3139
3151
|
task_group_segment=state.task_group,
|
|
3140
3152
|
task_id_segment=state.task_id,
|
|
3141
|
-
|
|
3142
|
-
# 브랜치를 만든다. 이 미리보기 브랜치명은 실제 생성 브랜치와 정확히 일치한다.
|
|
3143
|
-
work_category="",
|
|
3153
|
+
work_category=_preview_work_category(state),
|
|
3144
3154
|
base_ref=state.base_ref,
|
|
3145
3155
|
)
|
|
3146
3156
|
key = {
|
|
@@ -3167,7 +3177,8 @@ def _worktree_preview_line_impl(state: WizardState) -> str:
|
|
|
3167
3177
|
path=str(parent_dir))
|
|
3168
3178
|
decision = preview_stage_worktree_decision(
|
|
3169
3179
|
project_id=state.project_id, task_group_segment=state.task_group,
|
|
3170
|
-
task_id_segment=state.task_id,
|
|
3180
|
+
task_id_segment=state.task_id,
|
|
3181
|
+
work_category=_preview_work_category(state),
|
|
3171
3182
|
stage_number=int(stage),
|
|
3172
3183
|
)
|
|
3173
3184
|
key = ("worktree_impl_reuse" if decision.status == "reused"
|
|
@@ -8,6 +8,11 @@ profile / validators / wizard 는 이 모듈에서 enum 을 import 해야 한다
|
|
|
8
8
|
"""
|
|
9
9
|
from __future__ import annotations
|
|
10
10
|
|
|
11
|
+
import json
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from .paths import task_dir
|
|
15
|
+
|
|
11
16
|
WORK_CATEGORIES: tuple[str, ...] = (
|
|
12
17
|
"bugfix",
|
|
13
18
|
"feature",
|
|
@@ -16,6 +21,38 @@ WORK_CATEGORIES: tuple[str, ...] = (
|
|
|
16
21
|
"improvement",
|
|
17
22
|
)
|
|
18
23
|
|
|
24
|
+
DEFAULT_WORK_CATEGORY = "feature"
|
|
25
|
+
|
|
19
26
|
|
|
20
27
|
def is_valid_category(value: str) -> bool:
|
|
21
28
|
return value in WORK_CATEGORIES
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def recorded_work_category(project_root: Path, task_group: str, task_id: str) -> str:
|
|
32
|
+
"""task-manifest.json 에 이미 기록된 work-category. 없거나 enum 밖이면 ""."""
|
|
33
|
+
manifest = task_dir(project_root, task_group, task_id) / "task-manifest.json"
|
|
34
|
+
try:
|
|
35
|
+
data = json.loads(manifest.read_text(encoding="utf-8"))
|
|
36
|
+
except (OSError, json.JSONDecodeError):
|
|
37
|
+
return ""
|
|
38
|
+
value = str(data.get("workCategory") or "").strip()
|
|
39
|
+
return value if is_valid_category(value) else ""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def resolve_work_category(
|
|
43
|
+
explicit: str, *, project_root: Path, task_group: str, task_id: str
|
|
44
|
+
) -> str:
|
|
45
|
+
"""이번 run 이 실제로 쓸 work-category: 명시값 → task 에 기록된 분류 →
|
|
46
|
+
DEFAULT_WORK_CATEGORY.
|
|
47
|
+
|
|
48
|
+
requirements-discovery 가 분류를 task-manifest 에 기록해두면 이후 phase 는
|
|
49
|
+
`--work-category` 없이 실행돼도 같은 branch namespace 로 수렴한다. 어느 쪽도
|
|
50
|
+
없을 때 `unknown`(→ `task/` 접두) 대신 기본 분류로 떨어뜨린다.
|
|
51
|
+
"""
|
|
52
|
+
value = (explicit or "").strip()
|
|
53
|
+
if value:
|
|
54
|
+
return value
|
|
55
|
+
return (
|
|
56
|
+
recorded_work_category(project_root, task_group, task_id)
|
|
57
|
+
or DEFAULT_WORK_CATEGORY
|
|
58
|
+
)
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Heartbeat primitives shared by the live liveness probe and the Phase 7 audit.
|
|
2
|
+
|
|
3
|
+
`claude-worker` appends `- PROGRESS: <stage> <ISO-8601-UTC>` lines to its audit
|
|
4
|
+
sidecar so a silent hang is visible while the run is still recoverable. The
|
|
5
|
+
Phase 7 validator has always parsed those lines post-hoc; `worker_liveness`
|
|
6
|
+
reads them mid-run. Both must agree on what "stale" means, so the line shape and
|
|
7
|
+
the cadence budget live here rather than in either consumer.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import re
|
|
12
|
+
from datetime import datetime, timezone
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
# claude-worker audit 사이드카의 heartbeat 라인 (claude-worker.md "Heartbeat").
|
|
16
|
+
HEARTBEAT_LINE_RE = re.compile(
|
|
17
|
+
r"^-[ \t]*PROGRESS:[ \t]*(?P<stage>\S+)[ \t]+(?P<ts>\S+)[ \t]*$", re.MULTILINE
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
# 계약상 cadence 는 5분. append 직전 측정한 시각과 실제 쓰기 사이 지연을 흡수하는
|
|
21
|
+
# 고정 grace 60초를 더한다.
|
|
22
|
+
HEARTBEAT_MAX_GAP_SECONDS = 5 * 60 + 60
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def parse_timestamp(raw: str) -> datetime | None:
|
|
26
|
+
"""A heartbeat's ISO-8601 timestamp as an aware datetime, or None."""
|
|
27
|
+
try:
|
|
28
|
+
parsed = datetime.fromisoformat(raw.replace("Z", "+00:00"))
|
|
29
|
+
except ValueError:
|
|
30
|
+
return None
|
|
31
|
+
return parsed if parsed.tzinfo else parsed.replace(tzinfo=timezone.utc)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def latest_heartbeat(sidecar: Path) -> datetime | None:
|
|
35
|
+
"""The newest parseable heartbeat in *sidecar*, or None when it has none.
|
|
36
|
+
|
|
37
|
+
Takes the maximum rather than the last line: a regressing timestamp is the
|
|
38
|
+
Phase 7 validator's finding to report, and treating it as "stale" here would
|
|
39
|
+
kill a worker that is in fact still writing.
|
|
40
|
+
"""
|
|
41
|
+
try:
|
|
42
|
+
text = sidecar.read_text(encoding="utf-8")
|
|
43
|
+
except OSError:
|
|
44
|
+
return None
|
|
45
|
+
stamps = [
|
|
46
|
+
parsed
|
|
47
|
+
for match in HEARTBEAT_LINE_RE.finditer(text)
|
|
48
|
+
if (parsed := parse_timestamp(match.group("ts"))) is not None
|
|
49
|
+
]
|
|
50
|
+
return max(stamps) if stamps else None
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""Mid-run liveness probe for pending workers (`okstra worker-liveness`).
|
|
2
|
+
|
|
3
|
+
The CLI wrappers reap a silent CLI themselves via their idle watchdog, but the
|
|
4
|
+
lead's own wait had no equivalent: it polled Result Paths, which only change at
|
|
5
|
+
the very end, so a worker that died at minute 3 was still waited on until its
|
|
6
|
+
20–40 minute deadline. Two contracts named that failure without a mechanism —
|
|
7
|
+
`adapters/claude-code.md` ("a missing or stale heartbeat consumes the same
|
|
8
|
+
one-retry budget") and the absence of any `did-not-launch` status at all.
|
|
9
|
+
|
|
10
|
+
This module is that mechanism. It reports, not decides: the lead reads the
|
|
11
|
+
verdict and spends its existing one-retry budget.
|
|
12
|
+
|
|
13
|
+
Two probes, matching the two ways a pending worker goes quiet:
|
|
14
|
+
|
|
15
|
+
* ``--audit`` — a `claude-worker` audit sidecar. Stale past the heartbeat
|
|
16
|
+
cadence (or present with no heartbeat at all) means the in-process worker
|
|
17
|
+
hung. Uses the same line shape and budget the Phase 7 validator applies.
|
|
18
|
+
* ``--prompt`` — a CLI-wrapper prompt-history path. Neither `<prompt>.log` nor
|
|
19
|
+
`<prompt>.status.json` past the launch grace means the wrapper never ran —
|
|
20
|
+
the dispatch itself failed, which no artifact otherwise distinguishes from a
|
|
21
|
+
worker that has merely not finished.
|
|
22
|
+
"""
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import argparse
|
|
26
|
+
import json
|
|
27
|
+
import sys
|
|
28
|
+
from datetime import datetime, timezone
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
|
|
31
|
+
from okstra_ctl.worker_heartbeat import HEARTBEAT_MAX_GAP_SECONDS, latest_heartbeat
|
|
32
|
+
|
|
33
|
+
DEFAULT_LAUNCH_GRACE_SECONDS = 60
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _log_path(prompt: Path) -> Path:
|
|
37
|
+
"""The wrapper's live log, named as okstra-*-exec.sh names it."""
|
|
38
|
+
return prompt.with_suffix(".log") if prompt.suffix == ".md" else Path(f"{prompt}.log")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def probe_heartbeat(sidecar: Path, now: datetime, max_idle: float) -> dict:
|
|
42
|
+
"""Liveness of one in-process worker, read from its audit sidecar."""
|
|
43
|
+
probe = {"kind": "heartbeat", "path": str(sidecar)}
|
|
44
|
+
if not sidecar.is_file():
|
|
45
|
+
# The worker writes the sidecar early but not instantly; absence is the
|
|
46
|
+
# launch probe's business, not a hang.
|
|
47
|
+
return {**probe, "state": "pending", "reason": "audit sidecar not written yet"}
|
|
48
|
+
beat = latest_heartbeat(sidecar)
|
|
49
|
+
if beat is None:
|
|
50
|
+
return {
|
|
51
|
+
**probe,
|
|
52
|
+
"state": "stalled",
|
|
53
|
+
"reason": "audit sidecar carries no `- PROGRESS:` heartbeat",
|
|
54
|
+
}
|
|
55
|
+
idle = (now - beat).total_seconds()
|
|
56
|
+
state = "stalled" if idle > max_idle else "live"
|
|
57
|
+
return {
|
|
58
|
+
**probe,
|
|
59
|
+
"state": state,
|
|
60
|
+
"idleSeconds": int(idle),
|
|
61
|
+
"lastHeartbeat": beat.isoformat(),
|
|
62
|
+
"reason": (
|
|
63
|
+
f"no heartbeat for {int(idle)}s (budget {int(max_idle)}s)"
|
|
64
|
+
if state == "stalled"
|
|
65
|
+
else ""
|
|
66
|
+
),
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def probe_launch(prompt: Path, now: datetime, grace: float) -> dict:
|
|
71
|
+
"""Whether the wrapper behind *prompt* ever started."""
|
|
72
|
+
log, status = _log_path(prompt), Path(f"{prompt}.status.json")
|
|
73
|
+
probe = {"kind": "launch", "path": str(prompt)}
|
|
74
|
+
if log.exists() or status.exists():
|
|
75
|
+
return {**probe, "state": "live", "reason": ""}
|
|
76
|
+
if not prompt.is_file():
|
|
77
|
+
return {**probe, "state": "pending", "reason": "prompt history not written yet"}
|
|
78
|
+
waited = now.timestamp() - prompt.stat().st_mtime
|
|
79
|
+
if waited <= grace:
|
|
80
|
+
return {**probe, "state": "pending", "reason": "within launch grace"}
|
|
81
|
+
return {
|
|
82
|
+
**probe,
|
|
83
|
+
"state": "did-not-launch",
|
|
84
|
+
"waitedSeconds": int(waited),
|
|
85
|
+
"reason": (
|
|
86
|
+
f"neither {log.name} nor {status.name} exists {int(waited)}s after the "
|
|
87
|
+
f"prompt was written (grace {int(grace)}s)"
|
|
88
|
+
),
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def probe_all(
|
|
93
|
+
audits: list[str],
|
|
94
|
+
prompts: list[str],
|
|
95
|
+
*,
|
|
96
|
+
now: datetime,
|
|
97
|
+
max_idle: float,
|
|
98
|
+
launch_grace: float,
|
|
99
|
+
) -> dict:
|
|
100
|
+
probes = [probe_heartbeat(Path(p), now, max_idle) for p in audits]
|
|
101
|
+
probes += [probe_launch(Path(p), now, launch_grace) for p in prompts]
|
|
102
|
+
unhealthy = [p for p in probes if p["state"] in ("stalled", "did-not-launch")]
|
|
103
|
+
return {"ok": not unhealthy, "checkedAt": now.isoformat(), "probes": probes,
|
|
104
|
+
"unhealthy": unhealthy}
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def main(argv: list[str] | None = None) -> int:
|
|
108
|
+
parser = argparse.ArgumentParser(
|
|
109
|
+
prog="okstra worker-liveness",
|
|
110
|
+
description="Report whether pending workers are still alive (read-only).",
|
|
111
|
+
)
|
|
112
|
+
parser.add_argument("--audit", action="append", default=[],
|
|
113
|
+
help="claude-worker audit sidecar path (repeatable)")
|
|
114
|
+
parser.add_argument("--prompt", action="append", default=[],
|
|
115
|
+
help="CLI-wrapper prompt-history path (repeatable)")
|
|
116
|
+
parser.add_argument("--max-idle", type=float, default=HEARTBEAT_MAX_GAP_SECONDS,
|
|
117
|
+
help="heartbeat staleness budget in seconds")
|
|
118
|
+
parser.add_argument("--launch-grace", type=float, default=DEFAULT_LAUNCH_GRACE_SECONDS,
|
|
119
|
+
help="seconds a wrapper may take to write its first artifact")
|
|
120
|
+
parser.add_argument("--json", action="store_true", help="emit JSON (always on)")
|
|
121
|
+
args = parser.parse_args(argv)
|
|
122
|
+
|
|
123
|
+
if not args.audit and not args.prompt:
|
|
124
|
+
parser.error("pass at least one --audit or --prompt path")
|
|
125
|
+
|
|
126
|
+
result = probe_all(
|
|
127
|
+
args.audit,
|
|
128
|
+
args.prompt,
|
|
129
|
+
now=datetime.now(timezone.utc),
|
|
130
|
+
max_idle=args.max_idle,
|
|
131
|
+
launch_grace=args.launch_grace,
|
|
132
|
+
)
|
|
133
|
+
print(json.dumps(result, ensure_ascii=False, indent=2))
|
|
134
|
+
# Non-zero on an unhealthy worker so a poll loop can branch on the exit code
|
|
135
|
+
# without parsing the JSON.
|
|
136
|
+
return 0 if result["ok"] else 1
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
if __name__ == "__main__":
|
|
140
|
+
raise SystemExit(main(sys.argv[1:]))
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
"""Compact initial final-verification prompt contract."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import re
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Mapping
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
MAX_FINAL_VERIFICATION_DIRECTIVE_LINES = 40
|
|
10
|
+
MAX_FINAL_VERIFICATION_BODY_LINES = 96
|
|
11
|
+
|
|
12
|
+
_DIRECTIVE_HEADING = "## Run-specific directive"
|
|
13
|
+
_PRIMARY_PACKET_RE = re.compile(
|
|
14
|
+
r"(?im)^-\s+Primary analysis packet:\s+`[^`\n]*analysis-packet\.md`\s*$"
|
|
15
|
+
)
|
|
16
|
+
_COPIED_SECTION_PATTERNS = (
|
|
17
|
+
("Primary focus areas", re.compile(r"(?im)^(?:#{1,6}\s+|-\s+)Primary focus areas\b")),
|
|
18
|
+
(
|
|
19
|
+
"Required deliverable shape",
|
|
20
|
+
re.compile(r"(?im)^(?:#{1,6}\s+|-\s+)Required deliverable shape\b"),
|
|
21
|
+
),
|
|
22
|
+
(
|
|
23
|
+
"Self-review pass",
|
|
24
|
+
re.compile(r"(?im)^(?:#{1,6}\s+|-\s+)Self-review pass\b"),
|
|
25
|
+
),
|
|
26
|
+
)
|
|
27
|
+
_NON_BODY_PREFIXES = (
|
|
28
|
+
"**Project Root:**",
|
|
29
|
+
"**Prompt History Path:**",
|
|
30
|
+
"**Result Path:**",
|
|
31
|
+
"Assigned worker prompt history path:",
|
|
32
|
+
"**Worker Preamble Path:**",
|
|
33
|
+
"**Errors log path:**",
|
|
34
|
+
"**Errors sidecar path:**",
|
|
35
|
+
"**Read scope:**",
|
|
36
|
+
"**Worktree:**",
|
|
37
|
+
"**Verification scope:**",
|
|
38
|
+
"**Verification base ref:**",
|
|
39
|
+
"**Verification head ref:**",
|
|
40
|
+
"**Verification target path:**",
|
|
41
|
+
"**Verification target digest:**",
|
|
42
|
+
)
|
|
43
|
+
_REQUIRED_TARGET_PREFIXES = (
|
|
44
|
+
"**Worktree:**",
|
|
45
|
+
"**Verification scope:**",
|
|
46
|
+
"**Verification base ref:**",
|
|
47
|
+
"**Verification head ref:**",
|
|
48
|
+
"**Verification target path:**",
|
|
49
|
+
"**Verification target digest:**",
|
|
50
|
+
)
|
|
51
|
+
_WORKER_SPECIFIC_PREFIXES = (
|
|
52
|
+
"**Prompt History Path:**",
|
|
53
|
+
"**Result Path:**",
|
|
54
|
+
"Assigned worker prompt history path:",
|
|
55
|
+
"**Errors sidecar path:**",
|
|
56
|
+
"**Worker Result Path:**",
|
|
57
|
+
"**Model:**",
|
|
58
|
+
"**Pane role:**",
|
|
59
|
+
)
|
|
60
|
+
_WORKER_LABEL_RE = re.compile(
|
|
61
|
+
r"\b(?:Claude|Codex|Antigravity) worker\b",
|
|
62
|
+
re.IGNORECASE,
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def validate_final_verification_initial_prompt(text: str) -> list[str]:
|
|
67
|
+
"""Return deterministic compact-prompt contract violations."""
|
|
68
|
+
errors: list[str] = []
|
|
69
|
+
_reject_literal(
|
|
70
|
+
text,
|
|
71
|
+
"**Coding preflight pack:**",
|
|
72
|
+
"Coding preflight pack is forbidden for final-verification",
|
|
73
|
+
errors,
|
|
74
|
+
)
|
|
75
|
+
_reject_literal(
|
|
76
|
+
text,
|
|
77
|
+
"**Verification diff stat:**",
|
|
78
|
+
"inline Verification diff stat is forbidden; use verification-target.md",
|
|
79
|
+
errors,
|
|
80
|
+
)
|
|
81
|
+
_reject_literal(
|
|
82
|
+
text,
|
|
83
|
+
"## Source / fallback paths",
|
|
84
|
+
"Source / fallback paths is forbidden; use analysis-packet.md",
|
|
85
|
+
errors,
|
|
86
|
+
)
|
|
87
|
+
for label, pattern in _COPIED_SECTION_PATTERNS:
|
|
88
|
+
if pattern.search(text):
|
|
89
|
+
errors.append(f"copied {label} section is forbidden; use analysis-packet.md")
|
|
90
|
+
_validate_compact_target_identity(text, errors)
|
|
91
|
+
packet_count = len(_PRIMARY_PACKET_RE.findall(text))
|
|
92
|
+
if packet_count != 1:
|
|
93
|
+
errors.append(
|
|
94
|
+
"exactly one Primary analysis packet path is required "
|
|
95
|
+
f"(found {packet_count})"
|
|
96
|
+
)
|
|
97
|
+
directive_count = text.count(_DIRECTIVE_HEADING)
|
|
98
|
+
if directive_count > 1:
|
|
99
|
+
errors.append("at most one Run-specific directive section is allowed")
|
|
100
|
+
if directive_count == 1:
|
|
101
|
+
directive_lines = _directive_nonblank_lines(text)
|
|
102
|
+
if directive_lines > MAX_FINAL_VERIFICATION_DIRECTIVE_LINES:
|
|
103
|
+
errors.append(
|
|
104
|
+
"Run-specific directive exceeds "
|
|
105
|
+
f"{MAX_FINAL_VERIFICATION_DIRECTIVE_LINES} nonblank lines "
|
|
106
|
+
f"(found {directive_lines})"
|
|
107
|
+
)
|
|
108
|
+
body_lines = _body_nonblank_lines(text)
|
|
109
|
+
if body_lines > MAX_FINAL_VERIFICATION_BODY_LINES:
|
|
110
|
+
errors.append(
|
|
111
|
+
f"prompt body exceeds {MAX_FINAL_VERIFICATION_BODY_LINES} "
|
|
112
|
+
f"nonblank lines (found {body_lines})"
|
|
113
|
+
)
|
|
114
|
+
return errors
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def normalise_analysis_prompt(text: str) -> str:
|
|
118
|
+
"""Remove only permitted worker identity, model, role, and path deltas."""
|
|
119
|
+
normalized: list[str] = []
|
|
120
|
+
for line in text.replace("\r\n", "\n").replace("\r", "\n").splitlines():
|
|
121
|
+
stripped = line.strip()
|
|
122
|
+
prefix = next(
|
|
123
|
+
(candidate for candidate in _WORKER_SPECIFIC_PREFIXES if stripped.startswith(candidate)),
|
|
124
|
+
"",
|
|
125
|
+
)
|
|
126
|
+
if prefix:
|
|
127
|
+
normalized.append(f"{prefix} <worker-specific>")
|
|
128
|
+
continue
|
|
129
|
+
normalized.append(_WORKER_LABEL_RE.sub("<analysis-worker>", line.rstrip()))
|
|
130
|
+
return "\n".join(normalized).strip() + "\n"
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def validate_analysis_prompt_set(prompts: Mapping[str, str]) -> list[str]:
|
|
134
|
+
"""Require byte-identical normalized bodies for initial analysis workers."""
|
|
135
|
+
if len(prompts) < 2:
|
|
136
|
+
return []
|
|
137
|
+
normalized = {
|
|
138
|
+
worker_id: normalise_analysis_prompt(text)
|
|
139
|
+
for worker_id, text in sorted(prompts.items())
|
|
140
|
+
}
|
|
141
|
+
baseline_worker = next(iter(normalized))
|
|
142
|
+
baseline = normalized[baseline_worker]
|
|
143
|
+
different = [
|
|
144
|
+
worker_id
|
|
145
|
+
for worker_id, body in normalized.items()
|
|
146
|
+
if body != baseline
|
|
147
|
+
]
|
|
148
|
+
if not different:
|
|
149
|
+
return []
|
|
150
|
+
workers = ", ".join([baseline_worker, *different])
|
|
151
|
+
return [f"normalized initial analysis prompts differ across workers: {workers}"]
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def validate_final_verification_prompt_paths(
|
|
155
|
+
prompt_paths: Mapping[str, Path],
|
|
156
|
+
) -> list[str]:
|
|
157
|
+
"""Validate persisted initial prompts and their normalized equality."""
|
|
158
|
+
prompts: dict[str, str] = {}
|
|
159
|
+
errors: list[str] = []
|
|
160
|
+
for worker_id, path in sorted(prompt_paths.items()):
|
|
161
|
+
try:
|
|
162
|
+
text = path.read_text(encoding="utf-8")
|
|
163
|
+
except OSError as exc:
|
|
164
|
+
errors.append(f"{worker_id}: cannot read prompt {path}: {exc}")
|
|
165
|
+
continue
|
|
166
|
+
prompts[worker_id] = text
|
|
167
|
+
errors.extend(
|
|
168
|
+
f"{worker_id}: {error}"
|
|
169
|
+
for error in validate_final_verification_initial_prompt(text)
|
|
170
|
+
)
|
|
171
|
+
errors.extend(validate_analysis_prompt_set(prompts))
|
|
172
|
+
return errors
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _reject_literal(
|
|
176
|
+
text: str,
|
|
177
|
+
literal: str,
|
|
178
|
+
message: str,
|
|
179
|
+
errors: list[str],
|
|
180
|
+
) -> None:
|
|
181
|
+
if literal in text:
|
|
182
|
+
errors.append(message)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _validate_compact_target_identity(text: str, errors: list[str]) -> None:
|
|
186
|
+
stripped_lines = [line.strip() for line in text.splitlines()]
|
|
187
|
+
for prefix in _REQUIRED_TARGET_PREFIXES:
|
|
188
|
+
matches = [line for line in stripped_lines if line.startswith(prefix)]
|
|
189
|
+
if len(matches) != 1 or not matches[0][len(prefix):].strip():
|
|
190
|
+
errors.append(f"exactly one non-empty {prefix} header is required")
|
|
191
|
+
scope_line = next(
|
|
192
|
+
(line for line in stripped_lines if line.startswith("**Verification scope:**")),
|
|
193
|
+
"",
|
|
194
|
+
)
|
|
195
|
+
scope = scope_line.removeprefix("**Verification scope:**").strip()
|
|
196
|
+
if scope and scope not in {"whole-task", "single-stage"}:
|
|
197
|
+
errors.append("Verification scope must be whole-task or single-stage")
|
|
198
|
+
digest_line = next(
|
|
199
|
+
(
|
|
200
|
+
line
|
|
201
|
+
for line in stripped_lines
|
|
202
|
+
if line.startswith("**Verification target digest:**")
|
|
203
|
+
),
|
|
204
|
+
"",
|
|
205
|
+
)
|
|
206
|
+
digest = digest_line.removeprefix("**Verification target digest:**").strip()
|
|
207
|
+
if digest and not re.fullmatch(r"sha256:[0-9a-f]{64}", digest):
|
|
208
|
+
errors.append("Verification target digest must be sha256:<64 lowercase hex>")
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _directive_nonblank_lines(text: str) -> int:
|
|
212
|
+
section = text.split(_DIRECTIVE_HEADING, 1)[1]
|
|
213
|
+
section = re.split(r"(?m)^##\s+", section, maxsplit=1)[0]
|
|
214
|
+
return sum(1 for line in section.splitlines() if line.strip())
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _body_nonblank_lines(text: str) -> int:
|
|
218
|
+
return sum(
|
|
219
|
+
1
|
|
220
|
+
for line in text.splitlines()
|
|
221
|
+
if line.strip()
|
|
222
|
+
and not any(line.strip().startswith(prefix) for prefix in _NON_BODY_PREFIXES)
|
|
223
|
+
)
|
|
@@ -11,6 +11,25 @@ class WorkerPromptHeaderError(Exception):
|
|
|
11
11
|
"""Raised when worker prompt anchor headers cannot be rendered."""
|
|
12
12
|
|
|
13
13
|
|
|
14
|
+
# The Worker Preamble's "Reading rules" already allowlist the worker's reads to
|
|
15
|
+
# okstra-enumerated paths, but the preamble is a *path* the worker opens partway
|
|
16
|
+
# into its run — by then a host SessionStart hook or a global CLAUDE.md /
|
|
17
|
+
# AGENTS.md project-conditional has already told it to read `graphify-out/`,
|
|
18
|
+
# skill catalogs, and similar non-okstra artifacts. Restating the scope inline
|
|
19
|
+
# puts the rule in front of the worker before its first tool call, which is the
|
|
20
|
+
# only point at which it can still win over those host instructions.
|
|
21
|
+
READ_SCOPE_HEADER = (
|
|
22
|
+
"**Read scope:** Read only the paths this prompt enumerates "
|
|
23
|
+
"(`[Required reading]`, `## Inputs`, verification-target paths) plus "
|
|
24
|
+
"source/evidence paths a finding must cite. Host session instructions "
|
|
25
|
+
"(SessionStart hooks, global `CLAUDE.md` / `AGENTS.md`, skill catalogs) do "
|
|
26
|
+
"NOT apply inside an okstra worker run: do not auto-read `graphify-out/`, "
|
|
27
|
+
"`SKILL.md`, or other artifacts outside `<PROJECT_ROOT>/.okstra/`. If an "
|
|
28
|
+
"un-enumerated file seems essential, record it under *Missing Information "
|
|
29
|
+
"or Assumptions* instead of reading it."
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
14
33
|
def worker_prompt_headers(
|
|
15
34
|
*,
|
|
16
35
|
project_root: Path,
|
|
@@ -22,18 +41,48 @@ def worker_prompt_headers(
|
|
|
22
41
|
) -> list[str]:
|
|
23
42
|
"""Render the team-contract worker prompt anchor headers."""
|
|
24
43
|
prompt_path = _resolve_project_path(project_root, prompt_rel)
|
|
25
|
-
|
|
44
|
+
headers = [
|
|
26
45
|
f"**Project Root:** {project_root}",
|
|
27
46
|
f"**Prompt History Path:** {prompt_rel}",
|
|
28
47
|
f"**Result Path:** {result_rel}",
|
|
29
48
|
f"Assigned worker prompt history path: {prompt_path}",
|
|
30
49
|
f"**Worker Preamble Path:** {_worker_preamble_path()}",
|
|
31
|
-
|
|
50
|
+
]
|
|
51
|
+
if _string_value(manifest.get("taskType")) == "implementation":
|
|
52
|
+
headers.append(
|
|
53
|
+
f"**Coding preflight pack:** {_coding_preflight_pack_path(active_context)}"
|
|
54
|
+
)
|
|
55
|
+
headers.extend([
|
|
32
56
|
f"**Errors log path:** {_errors_log_path(project_root, manifest, active_context)}",
|
|
33
57
|
(
|
|
34
58
|
f"**Errors sidecar path:** "
|
|
35
59
|
f"{_worker_errors_sidecar_path(project_root, manifest, active_context, worker_id)}"
|
|
36
60
|
),
|
|
61
|
+
READ_SCOPE_HEADER,
|
|
62
|
+
])
|
|
63
|
+
if _string_value(manifest.get("taskType")) == "final-verification":
|
|
64
|
+
headers.extend(_final_verification_target_headers(active_context))
|
|
65
|
+
return headers
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _final_verification_target_headers(
|
|
69
|
+
active_context: Mapping[str, Any],
|
|
70
|
+
) -> list[str]:
|
|
71
|
+
target = active_context.get("verificationTarget")
|
|
72
|
+
if not isinstance(target, Mapping):
|
|
73
|
+
return []
|
|
74
|
+
fields = (
|
|
75
|
+
("Worktree", "worktreePath"),
|
|
76
|
+
("Verification scope", "scope"),
|
|
77
|
+
("Verification base ref", "baseRef"),
|
|
78
|
+
("Verification head ref", "headRef"),
|
|
79
|
+
("Verification target path", "path"),
|
|
80
|
+
("Verification target digest", "digest"),
|
|
81
|
+
)
|
|
82
|
+
return [
|
|
83
|
+
f"**{label}:** {_string_value(target.get(key))}"
|
|
84
|
+
for label, key in fields
|
|
85
|
+
if _string_value(target.get(key))
|
|
37
86
|
]
|
|
38
87
|
|
|
39
88
|
|
|
@@ -464,14 +464,14 @@ Carried-forward plan items retain their prior verdicts verbatim; each such item
|
|
|
464
464
|
|
|
465
465
|
- Path (project-relative): `{{ releaseHandoff.sourceVerificationReport.path }}`
|
|
466
466
|
- Quoted `Verdict Token` row from that report's `## 7.` table:
|
|
467
|
-
> {{ releaseHandoff.sourceVerificationReport.verdictTokenQuote }}
|
|
467
|
+
> {{ releaseHandoff.sourceVerificationReport.verdictTokenQuote | mdquote(2) }}
|
|
468
468
|
|
|
469
469
|
### 5.6.2 Feature Branch & Working-Tree State{% if t("releaseHandoff.branchStateAside") != "captured at run start" %} ({{ t("releaseHandoff.branchStateAside") }}){% endif %}
|
|
470
470
|
|
|
471
471
|
- Feature branch: `{{ releaseHandoff.featureBranchState.branchName }}`
|
|
472
472
|
- {{ t("releaseHandoff.gitStatusShortLabel") }}:
|
|
473
473
|
```
|
|
474
|
-
{{ releaseHandoff.featureBranchState.gitStatusShort }}
|
|
474
|
+
{{ releaseHandoff.featureBranchState.gitStatusShort | indent(2) }}
|
|
475
475
|
```
|
|
476
476
|
- {{ t("releaseHandoff.existingPrLabel") }}: `{{ releaseHandoff.featureBranchState.existingPrUrl or '(none)' }}`
|
|
477
477
|
|
|
@@ -542,7 +542,7 @@ Carried-forward plan items retain their prior verdicts verbatim; each such item
|
|
|
542
542
|
|
|
543
543
|
- Plan file (project-relative): `{{ implementation.approvedPlanReference.planFile }}`
|
|
544
544
|
- Approval evidence:
|
|
545
|
-
> {{ implementation.approvedPlanReference.approvalEvidence }}
|
|
545
|
+
> {{ implementation.approvedPlanReference.approvalEvidence | mdquote(2) }}
|
|
546
546
|
- {{ t("executionMeta.runExecutorWorktreePath") }}: `{{ implementation.approvedPlanReference.executorWorktreePath }}`
|
|
547
547
|
- {{ t("executionMeta.runBaseRef") }}: `{{ implementation.approvedPlanReference.baseRefSha }}`
|
|
548
548
|
|
|
@@ -587,11 +587,11 @@ Carried-forward plan items retain their prior verdicts verbatim; each such item
|
|
|
587
587
|
- Stage: `{{ implementation.stageSidecarEvidence.stageNumber }}` — {{ implementation.stageSidecarEvidence.stageTitle }}
|
|
588
588
|
- Carry sidecar JSON:
|
|
589
589
|
```json
|
|
590
|
-
{{ implementation.stageSidecarEvidence.carryJson }}
|
|
590
|
+
{{ implementation.stageSidecarEvidence.carryJson | indent(2) }}
|
|
591
591
|
```
|
|
592
592
|
- Appended `consumers.jsonl` rows:
|
|
593
593
|
{% for row in implementation.stageSidecarEvidence.consumerRows -%}
|
|
594
|
-
> {{ row }}
|
|
594
|
+
> {{ row | mdquote(2) }}
|
|
595
595
|
{% endfor %}
|
|
596
596
|
|
|
597
597
|
### 5.7.6 Validation Evidence
|
|
@@ -647,19 +647,19 @@ Carried-forward plan items retain their prior verdicts verbatim; each such item
|
|
|
647
647
|
|
|
648
648
|
- Path (project-relative): `{{ finalVerification.sourceImplementationReport.path }}`
|
|
649
649
|
- {{ t("evidenceMeta.commitListSummary") }}:
|
|
650
|
-
> {{ finalVerification.sourceImplementationReport.commitListQuote }}
|
|
650
|
+
> {{ finalVerification.sourceImplementationReport.commitListQuote | mdquote(2) }}
|
|
651
651
|
- {{ t("evidenceMeta.diffSummaryQuote") }}:
|
|
652
|
-
> {{ finalVerification.sourceImplementationReport.diffSummaryQuote }}
|
|
652
|
+
> {{ finalVerification.sourceImplementationReport.diffSummaryQuote | mdquote(2) }}
|
|
653
653
|
- {{ t("evidenceMeta.targetWorktreePath") }}: `{{ finalVerification.sourceImplementationReport.worktreePath }}`
|
|
654
654
|
- {{ t("evidenceMeta.implementationBaseRef") }}: `{{ finalVerification.sourceImplementationReport.implementationBaseRef }}`
|
|
655
655
|
- {{ t("evidenceMeta.capturedHeadSha") }}: `{{ finalVerification.sourceImplementationReport.capturedHeadSha }}`
|
|
656
656
|
- {{ t("evidenceMeta.gitStatusAtRunStart") }}:
|
|
657
657
|
```
|
|
658
|
-
{{ finalVerification.sourceImplementationReport.gitStatusShort }}
|
|
658
|
+
{{ finalVerification.sourceImplementationReport.gitStatusShort | indent(2) }}
|
|
659
659
|
```
|
|
660
660
|
- {{ t("evidenceMeta.gitDiffStatAtRunStart") }}:
|
|
661
661
|
```
|
|
662
|
-
{{ finalVerification.sourceImplementationReport.gitDiffStat }}
|
|
662
|
+
{{ finalVerification.sourceImplementationReport.gitDiffStat | indent(2) }}
|
|
663
663
|
```
|
|
664
664
|
{% if verificationScope in ['whole-task', 'single-stage'] %}- {{ t("finalVerification.verificationScope") }}: `{{ verificationScope }}`
|
|
665
665
|
{% endif %}{% if finalVerification.stageReports %}- {{ t("finalVerification.stageReportsLabel") }}:
|