okstra 0.125.5 → 0.127.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.md +2 -1
- package/docs/project-structure-overview.md +2 -0
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/agents/workers/report-writer-worker.md +2 -2
- 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 +4 -1
- package/runtime/prompts/lead/okstra-lead-contract.md +24 -1
- package/runtime/prompts/lead/report-writer.md +1 -1
- package/runtime/prompts/lead/team-contract.md +3 -0
- package/runtime/prompts/profiles/_common-contract.md +1 -1
- package/runtime/python/okstra_ctl/design_surfaces.py +30 -14
- package/runtime/python/okstra_ctl/render_final_report.py +71 -5
- package/runtime/python/okstra_ctl/run.py +3 -1
- package/runtime/python/okstra_ctl/schema_excerpt.py +17 -1
- 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_headers.py +20 -0
- package/runtime/templates/reports/final-report.template.md +9 -9
- package/runtime/templates/worker-prompt-preamble.md +1 -0
- 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 +117 -7
- 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/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
|
@@ -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:]))
|
|
@@ -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,
|
|
@@ -34,6 +53,7 @@ def worker_prompt_headers(
|
|
|
34
53
|
f"**Errors sidecar path:** "
|
|
35
54
|
f"{_worker_errors_sidecar_path(project_root, manifest, active_context, worker_id)}"
|
|
36
55
|
),
|
|
56
|
+
READ_SCOPE_HEADER,
|
|
37
57
|
]
|
|
38
58
|
|
|
39
59
|
|
|
@@ -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") }}:
|
|
@@ -101,6 +101,7 @@ Every worker prompt MUST begin with these anchor headers, in this exact order, b
|
|
|
101
101
|
6. `**Coding preflight pack:** <absolute-path>` — points to the installed runtime resource pack under `<OKSTRA_HOME>/prompts/coding-preflight`. Implementation executors and verifiers use this to read `overview.md`, `clean-code.md`, and routed language/framework/architecture resources. Other worker roles may ignore it.
|
|
102
102
|
7. `**Errors log path:** <absolute-path>` — run-level JSONL (see Error reporting above).
|
|
103
103
|
8. `**Errors sidecar path:** <absolute-path>` — per-worker JSON (see Error reporting above).
|
|
104
|
+
9. `**Read scope:** …` — restates the *Reading rules* allowlist inline. This file is anchor 5, a path the worker opens partway into its run; by then a host SessionStart hook or a global `CLAUDE.md` / `AGENTS.md` project-conditional has already pointed it at `graphify-out/`, skill catalogs, and similar non-okstra artifacts. Only the inline copy arrives before the worker's first tool call.
|
|
104
105
|
|
|
105
106
|
**Exception — reverify dispatches.** A Phase 5.5 re-verification prompt (its `**Prompt History Path:**` carries a `-reverify-r<N>-` segment) deliberately omits anchors 5 and 6 and adds a `**Model:**` line — lightweight mode reads neither this file nor the preflight pack. The two errors-path anchors are NOT omitted; that gate applies to reverify prompts unchanged. The reverify anchor set is owned by `prompts/lead/convergence.md` "Required reverify-prompt anchor headers".
|
|
106
107
|
|
|
@@ -15,9 +15,10 @@ Bash tool_use 명령을 phase deny-list 와 대조해 위반을 기계적으로
|
|
|
15
15
|
한계 1: claude-code 세션 jsonl 만 스캔한다. codex/antigravity lead·worker 는 동일 형식의
|
|
16
16
|
tool_use transcript 를 ~/.claude/projects 에 남기지 않으므로 이 스캐너의 사정권 밖이며,
|
|
17
17
|
해당 런타임의 금지 행위는 여전히 수동 감사에 의존한다.
|
|
18
|
-
한계 2: deny-list 는
|
|
19
|
-
등장하는 경우(예: `git commit -m "... npm
|
|
20
|
-
`cd <path> && <cmd>` 형태 때문에 명령
|
|
18
|
+
한계 2: deny-list 는 heredoc 본문을 걷어낸(strip_heredoc_bodies) 명령 문자열 전체를
|
|
19
|
+
검색하므로, 금지 토큰이 인자/메시지에 등장하는 경우(예: `git commit -m "... npm
|
|
20
|
+
publish ..."`)는 여전히 위반으로 잡힐 수 있다. `cd <path> && <cmd>` 형태 때문에 명령
|
|
21
|
+
head 앵커링은 불가하므로 이 잔여 오탐은 감수한다.
|
|
21
22
|
"""
|
|
22
23
|
from __future__ import annotations
|
|
23
24
|
|
|
@@ -47,6 +48,43 @@ _UNIVERSAL = [
|
|
|
47
48
|
]
|
|
48
49
|
_NON_HANDOFF_PUSH = ("git push", re.compile(r"\bgit\s+push\b"))
|
|
49
50
|
|
|
51
|
+
_HEREDOC_START = re.compile(r"<<-?\s*(['\"]?)([A-Za-z_][A-Za-z0-9_]*)\1")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def strip_heredoc_bodies(command: str) -> str:
|
|
55
|
+
"""heredoc 본문을 걷어낸 명령 문자열.
|
|
56
|
+
|
|
57
|
+
heredoc 안의 토큰은 실행되는 명령이 아니라 파일에 **기록되는 데이터**다.
|
|
58
|
+
implementation-planning 은 배포 명령을 서술하는 계획서를 산출물로 내고 그
|
|
59
|
+
문서를 `python3 - <<'PY'` / `cat > … <<EOF` 로 패치하므로, 본문을 그대로
|
|
60
|
+
스캔하면 정상 경로에서 매 run 오탐이 난다. heredoc 시작 줄과 종료 구분자는
|
|
61
|
+
남겨 실제 명령 위치의 토큰은 계속 잡는다.
|
|
62
|
+
|
|
63
|
+
종료 구분자가 뒤에 없으면 아무것도 걷어내지 않는다 — `python3 -c "1 << shift"`
|
|
64
|
+
같은 시프트 연산이 heredoc 으로 오인돼 뒤따르는 진짜 명령을 숨기지 않도록,
|
|
65
|
+
본문 제거는 짝이 맞는 구분자를 실제로 찾았을 때만 한다.
|
|
66
|
+
"""
|
|
67
|
+
lines = command.split("\n")
|
|
68
|
+
kept: list[str] = []
|
|
69
|
+
index = 0
|
|
70
|
+
while index < len(lines):
|
|
71
|
+
line = lines[index]
|
|
72
|
+
kept.append(line)
|
|
73
|
+
index += 1
|
|
74
|
+
start = _HEREDOC_START.search(line)
|
|
75
|
+
if not start:
|
|
76
|
+
continue
|
|
77
|
+
delimiter = start.group(2)
|
|
78
|
+
end = next(
|
|
79
|
+
(i for i in range(index, len(lines)) if lines[i].strip() == delimiter),
|
|
80
|
+
None,
|
|
81
|
+
)
|
|
82
|
+
if end is None:
|
|
83
|
+
continue
|
|
84
|
+
kept.append(lines[end])
|
|
85
|
+
index = end + 1
|
|
86
|
+
return "\n".join(kept)
|
|
87
|
+
|
|
50
88
|
|
|
51
89
|
def forbidden_patterns_for(task_type: str) -> list[tuple[str, re.Pattern]]:
|
|
52
90
|
"""task_type(=phase profile) 의 deny-list. 모든 phase 가 _UNIVERSAL 을 받고,
|
|
@@ -104,8 +142,9 @@ def _scan_one(
|
|
|
104
142
|
if block.get("type") != "tool_use" or block.get("name") != "Bash":
|
|
105
143
|
continue
|
|
106
144
|
command = (block.get("input") or {}).get("command") or ""
|
|
145
|
+
executed = strip_heredoc_bodies(command)
|
|
107
146
|
for label, pattern in patterns:
|
|
108
|
-
if pattern.search(
|
|
147
|
+
if pattern.search(executed):
|
|
109
148
|
hits.append((label, command))
|
|
110
149
|
break # one violation per command — most-specific pattern wins
|
|
111
150
|
return hits
|
|
@@ -12,10 +12,12 @@ from dataclasses import dataclass
|
|
|
12
12
|
from pathlib import Path
|
|
13
13
|
from typing import List, Tuple
|
|
14
14
|
|
|
15
|
-
# scripts/
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
15
|
+
# scripts/ (repo) and python/ (installed under ~/.okstra/lib) are not packages;
|
|
16
|
+
# insert whichever exists so okstra_ctl is importable directly.
|
|
17
|
+
_VALIDATORS_DIR = Path(__file__).resolve().parent
|
|
18
|
+
for _ssot_dir in (_VALIDATORS_DIR.parent / "scripts", _VALIDATORS_DIR.parent / "python"):
|
|
19
|
+
if _ssot_dir.is_dir() and str(_ssot_dir) not in sys.path:
|
|
20
|
+
sys.path.insert(0, str(_ssot_dir))
|
|
19
21
|
|
|
20
22
|
from okstra_ctl.md_table import split_pipe_row # noqa: E402
|
|
21
23
|
|
|
@@ -24,10 +24,10 @@ import re
|
|
|
24
24
|
import sys
|
|
25
25
|
from pathlib import Path
|
|
26
26
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
if str(
|
|
30
|
-
|
|
27
|
+
_VALIDATORS_DIR = Path(__file__).resolve().parent
|
|
28
|
+
for _ssot_dir in (_VALIDATORS_DIR.parent / "scripts", _VALIDATORS_DIR.parent / "python"):
|
|
29
|
+
if _ssot_dir.is_dir() and str(_ssot_dir) not in sys.path:
|
|
30
|
+
sys.path.insert(0, str(_ssot_dir))
|
|
31
31
|
|
|
32
32
|
from okstra_ctl.clarification_items import ( # noqa: E402
|
|
33
33
|
parse_clarification_items,
|
|
@@ -11,13 +11,14 @@ import sys
|
|
|
11
11
|
from datetime import datetime, timezone
|
|
12
12
|
from pathlib import Path
|
|
13
13
|
|
|
14
|
-
# Make the
|
|
15
|
-
#
|
|
16
|
-
#
|
|
14
|
+
# Make the okstra packages importable however this validator is reached:
|
|
15
|
+
# ``scripts/`` next to the repo checkout, ``python/`` next to the installed
|
|
16
|
+
# copy under ~/.okstra/lib. The manifests advertise the bare script path, so
|
|
17
|
+
# it must self-bootstrap rather than rely on an inherited PYTHONPATH.
|
|
17
18
|
_VALIDATORS_DIR = Path(__file__).resolve().parent
|
|
18
|
-
|
|
19
|
-
if
|
|
20
|
-
|
|
19
|
+
for _ssot_dir in (_VALIDATORS_DIR.parent / "scripts", _VALIDATORS_DIR.parent / "python"):
|
|
20
|
+
if _ssot_dir.is_dir() and str(_ssot_dir) not in sys.path:
|
|
21
|
+
sys.path.insert(0, str(_ssot_dir))
|
|
21
22
|
|
|
22
23
|
try:
|
|
23
24
|
from okstra_ctl.final_report_schema import (
|
|
@@ -1529,6 +1530,32 @@ def _validate_verdict_card_consistency(content: str, failures: list[str]) -> Non
|
|
|
1529
1530
|
)
|
|
1530
1531
|
|
|
1531
1532
|
|
|
1533
|
+
def _validate_verdict_card_fields(data: dict, failures: list[str]) -> None:
|
|
1534
|
+
"""`verdictCard.direction` must byte-match its authoritative home in §7.
|
|
1535
|
+
|
|
1536
|
+
The markdown check above compares only the Verdict Token, so a Card whose
|
|
1537
|
+
Direction contradicted §7 shipped silently. `nextStep` is deliberately NOT
|
|
1538
|
+
compared: every shipped fixture authors the Card's cell as the actionable
|
|
1539
|
+
command (`/okstra-run task-type=release-handoff`) while §7 states the same
|
|
1540
|
+
action as prose ("Proceed to release-handoff."), so a byte-match rule there
|
|
1541
|
+
would reject the reference reports.
|
|
1542
|
+
"""
|
|
1543
|
+
card = data.get("verdictCard")
|
|
1544
|
+
final = data.get("finalVerdict")
|
|
1545
|
+
if not isinstance(card, dict) or not isinstance(final, dict):
|
|
1546
|
+
return
|
|
1547
|
+
card_value = str(card.get("direction") or "").strip()
|
|
1548
|
+
final_value = str(final.get("direction") or "").strip()
|
|
1549
|
+
if not card_value or not final_value or card_value == final_value:
|
|
1550
|
+
return
|
|
1551
|
+
failures.append(
|
|
1552
|
+
f"final-report data.json: verdictCard.direction `{card_value}` does not "
|
|
1553
|
+
f"match finalVerdict.direction `{final_value}` — the Card is a "
|
|
1554
|
+
"non-authoritative index and MUST byte-match §7 "
|
|
1555
|
+
"(_common-contract.md Verdict Card)."
|
|
1556
|
+
)
|
|
1557
|
+
|
|
1558
|
+
|
|
1532
1559
|
def _load_final_report_data(report_path: Path) -> dict:
|
|
1533
1560
|
"""Best-effort parse of the final-report data.json sibling. Returns {} when
|
|
1534
1561
|
absent or unparseable — those conditions are already surfaced as failures by
|
|
@@ -1596,6 +1623,7 @@ def validate_final_report_data(
|
|
|
1596
1623
|
|
|
1597
1624
|
_validate_rationale_evidence(data, failures)
|
|
1598
1625
|
_validate_no_opaque_id_references(data, failures)
|
|
1626
|
+
_validate_verdict_card_fields(data, failures)
|
|
1599
1627
|
# Phase-agnostic: the coverage critic runs in every finding-producing phase.
|
|
1600
1628
|
_validate_unverified_critic_gaps_recorded(data, failures)
|
|
1601
1629
|
|
|
@@ -1907,6 +1935,27 @@ _PLAN_ITEM_SOURCES = (
|
|
|
1907
1935
|
)
|
|
1908
1936
|
|
|
1909
1937
|
|
|
1938
|
+
def _plan_item_source_count(planning: dict, field: str) -> int:
|
|
1939
|
+
"""Row count for one plan-body deliverable array.
|
|
1940
|
+
|
|
1941
|
+
`stepwiseExecution` has two homes: the top-level array is the legacy flat
|
|
1942
|
+
summary (schema: "Legacy flat summary kept for compatibility only. New
|
|
1943
|
+
reports use stageMap/stages") and is absent from `implementationPlanning.
|
|
1944
|
+
required`, while a current report carries the real steps per stage. Counting
|
|
1945
|
+
only the legacy field made this whole check a no-op for the largest
|
|
1946
|
+
category — a modern plan could drop every `P-Step-*` item and still pass.
|
|
1947
|
+
"""
|
|
1948
|
+
rows = [r for r in (planning.get(field) or []) if isinstance(r, dict)]
|
|
1949
|
+
if field != "stepwiseExecution":
|
|
1950
|
+
return len(rows)
|
|
1951
|
+
per_stage = sum(
|
|
1952
|
+
len([s for s in (stage.get("stepwiseExecution") or []) if isinstance(s, dict)])
|
|
1953
|
+
for stage in (planning.get("stages") or [])
|
|
1954
|
+
if isinstance(stage, dict)
|
|
1955
|
+
)
|
|
1956
|
+
return per_stage or len(rows)
|
|
1957
|
+
|
|
1958
|
+
|
|
1910
1959
|
def _validate_plan_item_extraction_completeness(
|
|
1911
1960
|
data: dict,
|
|
1912
1961
|
failures: list[str],
|
|
@@ -1934,7 +1983,7 @@ def _validate_plan_item_extraction_completeness(
|
|
|
1934
1983
|
if isinstance(it, dict)
|
|
1935
1984
|
]
|
|
1936
1985
|
for field, prefix, label in _PLAN_ITEM_SOURCES:
|
|
1937
|
-
source_count =
|
|
1986
|
+
source_count = _plan_item_source_count(ip, field)
|
|
1938
1987
|
if source_count == 0:
|
|
1939
1988
|
continue
|
|
1940
1989
|
extracted = len(
|
|
@@ -3041,6 +3090,65 @@ def _validate_forbidden_actions(
|
|
|
3041
3090
|
failures.extend(f"forbidden-action: {v}" for v in violations)
|
|
3042
3091
|
|
|
3043
3092
|
|
|
3093
|
+
CLASSIFICATION_COUNT_KEYS = {
|
|
3094
|
+
"full-consensus": "fullConsensus",
|
|
3095
|
+
"partial-consensus": "partialConsensus",
|
|
3096
|
+
"contested": "contested",
|
|
3097
|
+
"worker-unique": "workerUnique",
|
|
3098
|
+
}
|
|
3099
|
+
|
|
3100
|
+
|
|
3101
|
+
def classification_counts_from_findings(findings) -> dict:
|
|
3102
|
+
"""Recompute `finalClassificationCounts` from `findings[].classification`.
|
|
3103
|
+
|
|
3104
|
+
Shared with `tests/contract/test_convergence_state_contract.py` so the
|
|
3105
|
+
invariant has one definition — it used to live only in that test, which
|
|
3106
|
+
meant it was checked against four hand-written fixtures and never against
|
|
3107
|
+
the state a lead actually writes.
|
|
3108
|
+
"""
|
|
3109
|
+
counts = {value: 0 for value in CLASSIFICATION_COUNT_KEYS.values()}
|
|
3110
|
+
for finding in findings or []:
|
|
3111
|
+
if not isinstance(finding, dict):
|
|
3112
|
+
continue
|
|
3113
|
+
key = CLASSIFICATION_COUNT_KEYS.get(finding.get("classification"))
|
|
3114
|
+
if key:
|
|
3115
|
+
counts[key] += 1
|
|
3116
|
+
return counts
|
|
3117
|
+
|
|
3118
|
+
|
|
3119
|
+
def _validate_convergence_state_counts(run_dir, failures) -> None:
|
|
3120
|
+
"""A run's convergence state must agree with its own findings list.
|
|
3121
|
+
|
|
3122
|
+
`finalClassificationCounts` is a lead-authored summary of
|
|
3123
|
+
`findings[].classification`. Nothing recomputed it against the real
|
|
3124
|
+
artifact, so a miscount survived into the report as two different truths
|
|
3125
|
+
(observed on dev-6901 planning 009: declared partial 6 / worker-unique 4
|
|
3126
|
+
against an actual 5 / 5). Downstream readers pick one arbitrarily.
|
|
3127
|
+
"""
|
|
3128
|
+
from pathlib import Path as _Path
|
|
3129
|
+
|
|
3130
|
+
state_dir = _Path(run_dir) / "state"
|
|
3131
|
+
if not state_dir.is_dir():
|
|
3132
|
+
return
|
|
3133
|
+
for state_path in sorted(state_dir.glob("convergence-*.json")):
|
|
3134
|
+
try:
|
|
3135
|
+
state = json.loads(state_path.read_text(encoding="utf-8"))
|
|
3136
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
3137
|
+
failures.append(f"convergence state {state_path.name} unreadable: {exc}")
|
|
3138
|
+
continue
|
|
3139
|
+
declared = state.get("finalClassificationCounts")
|
|
3140
|
+
if not isinstance(declared, dict):
|
|
3141
|
+
continue
|
|
3142
|
+
actual = classification_counts_from_findings(state.get("findings"))
|
|
3143
|
+
if declared != actual:
|
|
3144
|
+
failures.append(
|
|
3145
|
+
f"convergence state {state_path.name}: finalClassificationCounts "
|
|
3146
|
+
f"{declared} does not match findings[].classification {actual}. "
|
|
3147
|
+
"The counts are a summary of the findings list — a mismatch means "
|
|
3148
|
+
"the run reports two different classifications for the same work."
|
|
3149
|
+
)
|
|
3150
|
+
|
|
3151
|
+
|
|
3044
3152
|
def _validate_requirements_discovery_fanout(run_dir, failures) -> None:
|
|
3045
3153
|
"""requirements-discovery run 에 fan-out/ 이 있으면 packet+index 를 검증해
|
|
3046
3154
|
실패를 ``requirements-discovery: `` 접두로 folding 한다. fan-out 이 없으면 no-op.
|
|
@@ -3478,6 +3586,8 @@ def main() -> int:
|
|
|
3478
3586
|
if task_type == "requirements-discovery":
|
|
3479
3587
|
run_dir = report_path.parent.parent
|
|
3480
3588
|
_validate_requirements_discovery_fanout(run_dir, failures)
|
|
3589
|
+
# Phase-agnostic: convergence runs in every finding-producing phase.
|
|
3590
|
+
_validate_convergence_state_counts(report_path.parent.parent, failures)
|
|
3481
3591
|
validate_report_views(report_path, failures)
|
|
3482
3592
|
|
|
3483
3593
|
validation_status = "passed" if not failures else "failed"
|
|
@@ -17,10 +17,12 @@ import re
|
|
|
17
17
|
import sys
|
|
18
18
|
from pathlib import Path
|
|
19
19
|
|
|
20
|
-
# scripts/
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
20
|
+
# scripts/ (repo) and python/ (installed under ~/.okstra/lib) are not packages;
|
|
21
|
+
# insert whichever exists so okstra_ctl is importable directly.
|
|
22
|
+
_VALIDATORS_DIR = Path(__file__).resolve().parent
|
|
23
|
+
for _ssot_dir in (_VALIDATORS_DIR.parent / "scripts", _VALIDATORS_DIR.parent / "python"):
|
|
24
|
+
if _ssot_dir.is_dir() and str(_ssot_dir) not in sys.path:
|
|
25
|
+
sys.path.insert(0, str(_ssot_dir))
|
|
24
26
|
|
|
25
27
|
from okstra_ctl.md_table import split_pipe_row # noqa: E402
|
|
26
28
|
|
|
@@ -10,9 +10,10 @@ import sys
|
|
|
10
10
|
from dataclasses import dataclass, field
|
|
11
11
|
from pathlib import Path
|
|
12
12
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
13
|
+
_VALIDATORS_DIR = Path(__file__).resolve().parent
|
|
14
|
+
for _ssot_dir in (_VALIDATORS_DIR.parent / "scripts", _VALIDATORS_DIR.parent / "python"):
|
|
15
|
+
if _ssot_dir.is_dir() and str(_ssot_dir) not in sys.path:
|
|
16
|
+
sys.path.insert(0, str(_ssot_dir))
|
|
16
17
|
|
|
17
18
|
from okstra_ctl.work_categories import WORK_CATEGORIES # noqa: E402
|
|
18
19
|
from okstra_ctl.fanout import topological_order, CycleError # noqa: E402
|
|
@@ -12,10 +12,12 @@ import sys
|
|
|
12
12
|
from dataclasses import dataclass, field
|
|
13
13
|
from pathlib import Path
|
|
14
14
|
|
|
15
|
-
# scripts/
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
15
|
+
# scripts/ (repo) and python/ (installed under ~/.okstra/lib) are not packages;
|
|
16
|
+
# insert whichever exists so okstra_ctl is importable directly.
|
|
17
|
+
_VALIDATORS_DIR = Path(__file__).resolve().parent
|
|
18
|
+
for _ssot_dir in (_VALIDATORS_DIR.parent / "scripts", _VALIDATORS_DIR.parent / "python"):
|
|
19
|
+
if _ssot_dir.is_dir() and str(_ssot_dir) not in sys.path:
|
|
20
|
+
sys.path.insert(0, str(_ssot_dir))
|
|
19
21
|
|
|
20
22
|
from okstra_ctl.improvement_lenses import (
|
|
21
23
|
LENSES,
|
|
@@ -26,6 +26,18 @@ from dataclasses import dataclass, field
|
|
|
26
26
|
from datetime import datetime
|
|
27
27
|
from pathlib import Path
|
|
28
28
|
|
|
29
|
+
# scripts/ (repo) and python/ (installed under ~/.okstra/lib) are not packages;
|
|
30
|
+
# insert whichever exists so okstra_ctl is importable directly.
|
|
31
|
+
_VALIDATORS_DIR = Path(__file__).resolve().parent
|
|
32
|
+
for _ssot_dir in (_VALIDATORS_DIR.parent / "scripts", _VALIDATORS_DIR.parent / "python"):
|
|
33
|
+
if _ssot_dir.is_dir() and str(_ssot_dir) not in sys.path:
|
|
34
|
+
sys.path.insert(0, str(_ssot_dir))
|
|
35
|
+
|
|
36
|
+
from okstra_ctl.worker_heartbeat import ( # noqa: E402
|
|
37
|
+
HEARTBEAT_LINE_RE,
|
|
38
|
+
HEARTBEAT_MAX_GAP_SECONDS,
|
|
39
|
+
)
|
|
40
|
+
|
|
29
41
|
_DISPATCHED_STATUSES = {"completed", "timeout", "error", "in-progress"}
|
|
30
42
|
_WORKER_DISPATCH_MODES = {"cli-wrapper", "mixed", "tmux-pane"}
|
|
31
43
|
_ATTEMPTED_STATUSES = {"completed", "timeout", "error"}
|
|
@@ -39,13 +51,10 @@ _PROGRESS_LINE_RE = re.compile(
|
|
|
39
51
|
re.MULTILINE,
|
|
40
52
|
)
|
|
41
53
|
|
|
42
|
-
#
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
# 계약상 cadence 는 5분. append 직전 측정한 시각과 실제 쓰기 사이 지연을 흡수하는
|
|
47
|
-
# 고정 grace 60초를 더한다.
|
|
48
|
-
_HEARTBEAT_MAX_GAP_SECONDS = 5 * 60 + 60
|
|
54
|
+
# heartbeat 라인 shape 과 cadence 예산은 okstra_ctl.worker_heartbeat 정본을 쓴다 —
|
|
55
|
+
# `okstra worker-liveness` 가 run 도중 같은 판정을 내리므로 정의가 갈리면 안 된다.
|
|
56
|
+
_HEARTBEAT_LINE_RE = HEARTBEAT_LINE_RE
|
|
57
|
+
_HEARTBEAT_MAX_GAP_SECONDS = HEARTBEAT_MAX_GAP_SECONDS
|
|
49
58
|
|
|
50
59
|
# Phase 5/6 진입 전 lead 가 Read 해야 하는 implementation 프로파일 sidecar.
|
|
51
60
|
# 절대 경로는 레이어(repo / runtime / 설치본)마다 다르지만 basename 은 동일하다.
|
package/src/cli-registry.mjs
CHANGED
|
@@ -159,6 +159,13 @@ export const COMMAND_REGISTRY = [
|
|
|
159
159
|
category: "introspection",
|
|
160
160
|
summary: ["Inventory wrapper sidecar logs by size and task (read-only)"],
|
|
161
161
|
},
|
|
162
|
+
{
|
|
163
|
+
name: "worker-liveness",
|
|
164
|
+
module: "./commands/inspect/worker-liveness.mjs",
|
|
165
|
+
export: "run",
|
|
166
|
+
category: "introspection",
|
|
167
|
+
summary: ["Report whether pending workers are still alive (read-only)"],
|
|
168
|
+
},
|
|
162
169
|
{
|
|
163
170
|
name: "error-report",
|
|
164
171
|
module: "./commands/inspect/error-report.mjs",
|