stdtel 0.2.3__py3-none-any.whl
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.
- eval/__init__.py +0 -0
- eval/fixtures/fastapi-min/app/main.py +14 -0
- eval/power.py +199 -0
- eval/run_eval.py +139 -0
- stdtel/__init__.py +2 -0
- stdtel/doctor.py +211 -0
- stdtel/enrich.py +59 -0
- stdtel/exporter.py +113 -0
- stdtel/hooks/__init__.py +0 -0
- stdtel/hooks/cli.py +318 -0
- stdtel/install.py +173 -0
- stdtel/manifest.py +217 -0
- stdtel/policy_report.py +126 -0
- stdtel/skillmap.py +15 -0
- stdtel/spool.py +124 -0
- stdtel/spool_export.py +86 -0
- stdtel/state.py +118 -0
- stdtel/statusline.py +101 -0
- stdtel/transcript.py +174 -0
- stdtel-0.2.3.dist-info/METADATA +246 -0
- stdtel-0.2.3.dist-info/RECORD +24 -0
- stdtel-0.2.3.dist-info/WHEEL +5 -0
- stdtel-0.2.3.dist-info/entry_points.txt +9 -0
- stdtel-0.2.3.dist-info/top_level.txt +2 -0
eval/__init__.py
ADDED
|
File without changes
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""Minimal FastAPI app. The eval task asks for request logging to be added."""
|
|
2
|
+
from fastapi import FastAPI
|
|
3
|
+
|
|
4
|
+
app = FastAPI()
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@app.get("/health")
|
|
8
|
+
def health():
|
|
9
|
+
return {"status": "ok"}
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@app.post("/orders")
|
|
13
|
+
def create_order(order: dict):
|
|
14
|
+
return {"id": 1, "total": order.get("total", 0)}
|
eval/power.py
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
"""Sample-size arithmetic for the skill/harness evaluation design.
|
|
2
|
+
|
|
3
|
+
Every figure in docs/evaluation-power.md is produced here, so the numbers can be
|
|
4
|
+
re-derived rather than trusted. `--check` fails if the committed doc has drifted.
|
|
5
|
+
|
|
6
|
+
Two different questions, two different tests:
|
|
7
|
+
|
|
8
|
+
1. Does a skill raise the FIRST-TIME POLICY PASS RATE? A proportion, compared
|
|
9
|
+
between a with-skill and a without-skill arm.
|
|
10
|
+
2. Does a harness or skill change TOKENS PER MERGED PR? A continuous, strongly
|
|
11
|
+
right-skewed quantity, compared within developer under a crossover.
|
|
12
|
+
|
|
13
|
+
The second needs a design effect: PRs from one developer resemble each other, so
|
|
14
|
+
they carry less information than the same number of PRs from different people.
|
|
15
|
+
"""
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import argparse
|
|
19
|
+
import math
|
|
20
|
+
import re
|
|
21
|
+
import sys
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from statistics import NormalDist
|
|
24
|
+
|
|
25
|
+
DOC = Path(__file__).resolve().parent.parent / "docs" / "evaluation-power.md"
|
|
26
|
+
|
|
27
|
+
# The one sentence that must read identically everywhere it appears: README, the
|
|
28
|
+
# scorecard agent, stdtel-query and the power doc. A floor stated three different
|
|
29
|
+
# ways is three different floors, and the loosest one wins in practice.
|
|
30
|
+
HARD_FLOOR = ("below 30 merged PRs per arm, or fewer than 5 developers, report "
|
|
31
|
+
"descriptively and make no comparative claim")
|
|
32
|
+
FLOOR_SURFACES = ("README.md", "docs/evaluation-power.md",
|
|
33
|
+
"agents/skill-scorecard-analyst.md", "skills/stdtel-query/SKILL.md")
|
|
34
|
+
ALPHA, POWER = 0.05, 0.80
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def z(p: float) -> float:
|
|
38
|
+
return NormalDist().inv_cdf(p)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _zsum(alpha: float, power: float) -> float:
|
|
42
|
+
return z(1 - alpha / 2) + z(power)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def n_two_proportion(p1: float, p2: float, alpha: float = ALPHA, power: float = POWER) -> int:
|
|
46
|
+
"""Per-arm PRs to detect p1 -> p2, two independent proportions, two-sided.
|
|
47
|
+
|
|
48
|
+
n = (z_{1-a/2} + z_{1-b})^2 * [p1(1-p1) + p2(1-p2)] / (p1 - p2)^2
|
|
49
|
+
"""
|
|
50
|
+
if p1 == p2:
|
|
51
|
+
raise ValueError("no effect to detect")
|
|
52
|
+
return math.ceil(_zsum(alpha, power) ** 2
|
|
53
|
+
* (p1 * (1 - p1) + p2 * (1 - p2)) / (p1 - p2) ** 2)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def design_effect(prs_per_dev: int, icc: float) -> float:
|
|
57
|
+
"""DEFF = 1 + (m-1) * ICC. Clustering by developer inflates the requirement."""
|
|
58
|
+
return 1 + (prs_per_dev - 1) * icc
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def n_token_ratio(cv: float, rel_effect: float, icc: float, prs_per_dev: int,
|
|
62
|
+
alpha: float = ALPHA, power: float = POWER) -> int:
|
|
63
|
+
"""Per-arm PRs to detect a fractional change in mean tokens per PR.
|
|
64
|
+
|
|
65
|
+
n_iid = (z_{1-a/2} + z_{1-b})^2 * 2*CV^2 / effect^2 then x DEFF
|
|
66
|
+
|
|
67
|
+
Working on the ratio scale means the standard deviation enters as the
|
|
68
|
+
coefficient of variation, so no absolute token count is needed.
|
|
69
|
+
"""
|
|
70
|
+
n_iid = _zsum(alpha, power) ** 2 * 2 * cv ** 2 / rel_effect ** 2
|
|
71
|
+
return math.ceil(n_iid * design_effect(prs_per_dev, icc))
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def n_paired_by_developer(cv: float, rel_effect: float, rho: float = 0.5,
|
|
75
|
+
alpha: float = ALPHA, power: float = POWER) -> int:
|
|
76
|
+
"""Pairs (developer-periods) when each developer contributes ONE mean per arm.
|
|
77
|
+
|
|
78
|
+
Kept only to show why this framing is wrong for our data: collapsing a
|
|
79
|
+
developer's PRs to a single mean discards the within-developer variation the
|
|
80
|
+
crossover exists to exploit, and overstates what is needed by roughly 5x.
|
|
81
|
+
"""
|
|
82
|
+
sd_diff = cv * math.sqrt(2 * (1 - rho))
|
|
83
|
+
return math.ceil(_zsum(alpha, power) ** 2 * sd_diff ** 2 / rel_effect ** 2)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
# --- table rendering -------------------------------------------------------
|
|
87
|
+
|
|
88
|
+
def table_pass_rate() -> str:
|
|
89
|
+
rows = [(0.60, 0.80), (0.60, 0.75), (0.60, 0.70), (0.70, 0.85), (0.70, 0.80), (0.80, 0.90)]
|
|
90
|
+
out = [f"*alpha={ALPHA}, power={POWER:.0%}, two-sided, independent arms.*", "",
|
|
91
|
+
"| baseline | with skill | absolute lift | PRs per arm |",
|
|
92
|
+
"|---|---|---|---|"]
|
|
93
|
+
for p1, p2 in rows:
|
|
94
|
+
out.append(f"| {p1:.0%} | {p2:.0%} | {p2 - p1:+.0%} | **{n_two_proportion(p1, p2)}** |")
|
|
95
|
+
return "\n".join(out)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def table_token_ratio() -> str:
|
|
99
|
+
iccs = (0.1, 0.2, 0.4)
|
|
100
|
+
out = [f"*alpha={ALPHA}, power={POWER:.0%}, CV=0.9, m=10 PRs per developer per arm.*", "",
|
|
101
|
+
"| effect to detect | " + " | ".join(f"ICC={i}" for i in iccs) + " |",
|
|
102
|
+
"|---|" + "---|" * len(iccs)]
|
|
103
|
+
for eff in (0.40, 0.30, 0.20, 0.10):
|
|
104
|
+
cells = " | ".join(str(n_token_ratio(0.9, eff, i, 10)) for i in iccs)
|
|
105
|
+
out.append(f"| {eff:.0%} | {cells} |")
|
|
106
|
+
return "\n".join(out)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def table_cv_sensitivity() -> str:
|
|
110
|
+
cvs = (0.6, 0.9, 1.2)
|
|
111
|
+
out = ["*Same as above at ICC=0.2, varying only the coefficient of variation.*", "",
|
|
112
|
+
"| effect to detect | " + " | ".join(f"CV={c}" for c in cvs) + " |",
|
|
113
|
+
"|---|" + "---|" * len(cvs)]
|
|
114
|
+
for eff in (0.40, 0.30, 0.20):
|
|
115
|
+
cells = " | ".join(str(n_token_ratio(c, eff, 0.2, 10)) for c in cvs)
|
|
116
|
+
out.append(f"| {eff:.0%} | {cells} |")
|
|
117
|
+
return "\n".join(out)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def table_calendar() -> str:
|
|
121
|
+
need = n_token_ratio(0.9, 0.30, 0.2, 10)
|
|
122
|
+
out = [f"*A 30% token effect at CV=0.9, ICC=0.2 needs **{need} PRs per arm**. "
|
|
123
|
+
f"One crossover cycle = one fortnight per arm = 4 weeks.*", "",
|
|
124
|
+
"| team | PRs/dev/fortnight | PRs per arm per cycle | cycles | calendar |",
|
|
125
|
+
"|---|---|---|---|---|"]
|
|
126
|
+
for devs in (4, 8, 15, 30):
|
|
127
|
+
for prs in (3, 5):
|
|
128
|
+
per_cycle = devs * prs
|
|
129
|
+
cycles = math.ceil(need / per_cycle)
|
|
130
|
+
out.append(f"| {devs} devs | {prs} | {per_cycle} | {cycles} | ~{cycles * 4} weeks |")
|
|
131
|
+
return "\n".join(out)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def table_correction() -> str:
|
|
135
|
+
out = ["*Why the first attempt was wrong: pairing at developer level versus "
|
|
136
|
+
"PR level with developer as a blocking factor, both for a 30% effect at CV=0.9.*", "",
|
|
137
|
+
"| framing | unit of analysis | n required | in PRs (10 per dev per arm) |",
|
|
138
|
+
"|---|---|---|---|"]
|
|
139
|
+
pairs = n_paired_by_developer(0.9, 0.30)
|
|
140
|
+
prs = n_token_ratio(0.9, 0.30, 0.2, 10)
|
|
141
|
+
out.append(f"| paired by developer (**wrong**) | developer-periods | {pairs} pairs "
|
|
142
|
+
f"| {pairs * 10} |")
|
|
143
|
+
out.append(f"| PR-level, developer as block | PRs | {prs} PRs | {prs} |")
|
|
144
|
+
return "\n".join(out)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
TABLES = {
|
|
148
|
+
"pass-rate": table_pass_rate,
|
|
149
|
+
"token-ratio": table_token_ratio,
|
|
150
|
+
"cv-sensitivity": table_cv_sensitivity,
|
|
151
|
+
"calendar": table_calendar,
|
|
152
|
+
"correction": table_correction,
|
|
153
|
+
}
|
|
154
|
+
BLOCK = re.compile(r"<!-- generated:(?P<name>[\w-]+) -->.*?<!-- /generated -->", re.S)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def render(doc_text: str) -> tuple[str, list[str]]:
|
|
158
|
+
"""Fill every generated block; returns (text, names filled).
|
|
159
|
+
|
|
160
|
+
The caller checks that every table was placed. An earlier version reported
|
|
161
|
+
success by counting table *definitions* rather than substitutions, so a regex
|
|
162
|
+
that matched nothing still printed "regenerated 5 tables" and left the
|
|
163
|
+
document empty.
|
|
164
|
+
"""
|
|
165
|
+
filled: list[str] = []
|
|
166
|
+
|
|
167
|
+
def swap(m):
|
|
168
|
+
name = m.group("name")
|
|
169
|
+
if name not in TABLES:
|
|
170
|
+
raise SystemExit(f"unknown generated block: {name!r}")
|
|
171
|
+
filled.append(name)
|
|
172
|
+
return f"<!-- generated:{name} -->\n{TABLES[name]()}\n<!-- /generated -->"
|
|
173
|
+
|
|
174
|
+
return BLOCK.sub(swap, doc_text), filled
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def main(argv=None) -> int:
|
|
178
|
+
ap = argparse.ArgumentParser(description="regenerate the tables in docs/evaluation-power.md")
|
|
179
|
+
ap.add_argument("--check", action="store_true", help="fail if the doc is out of date")
|
|
180
|
+
a = ap.parse_args(argv)
|
|
181
|
+
current = DOC.read_text()
|
|
182
|
+
updated, filled = render(current)
|
|
183
|
+
missing = sorted(set(TABLES) - set(filled))
|
|
184
|
+
if missing:
|
|
185
|
+
print(f"no placeholder found for: {', '.join(missing)}", file=sys.stderr)
|
|
186
|
+
return 1
|
|
187
|
+
if a.check:
|
|
188
|
+
if current != updated:
|
|
189
|
+
print("docs/evaluation-power.md is stale; run `mise run power`", file=sys.stderr)
|
|
190
|
+
return 1
|
|
191
|
+
print(f"evaluation-power.md is up to date ({len(filled)} tables verified)")
|
|
192
|
+
return 0
|
|
193
|
+
DOC.write_text(updated)
|
|
194
|
+
print(f"wrote {len(filled)} table(s) into {DOC.name}: {', '.join(filled)}")
|
|
195
|
+
return 0
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
if __name__ == "__main__":
|
|
199
|
+
sys.exit(main())
|
eval/run_eval.py
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""Offline skill evaluation runner: headless harness run + OPA grading, recorded in std.eval.* form.
|
|
2
|
+
|
|
3
|
+
stdtel-eval --harness claude-code --tasks eval/tasks.yaml --out eval/results.jsonl [--dry-run]
|
|
4
|
+
|
|
5
|
+
Runners are pluggable; the default shells out to `claude -p` or `copilot` and `opa eval`.
|
|
6
|
+
`--dry-run` exercises the pipeline without calling any harness (used by CI smoke test).
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
import argparse, json, os, re, shutil, subprocess, sys, tempfile, time, uuid
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
import yaml
|
|
12
|
+
from stdtel.manifest import load_catalogue
|
|
13
|
+
|
|
14
|
+
def run_harness(harness: str, prompt: str, workdir: Path, skill_dir: Path | None, dry_run: bool) -> dict:
|
|
15
|
+
if dry_run:
|
|
16
|
+
return {"total_tokens": 1234 if skill_dir else 1500, "ok": True}
|
|
17
|
+
if harness == "claude-code":
|
|
18
|
+
env = dict(os.environ)
|
|
19
|
+
cmd = ["claude", "-p", prompt, "--output-format", "json"]
|
|
20
|
+
if skill_dir: cmd += ["--add-dir", str(skill_dir)]
|
|
21
|
+
out = subprocess.run(cmd, cwd=workdir, env=env, capture_output=True, text=True, timeout=900)
|
|
22
|
+
data = json.loads(out.stdout or "{}")
|
|
23
|
+
u = data.get("usage", {})
|
|
24
|
+
return {"total_tokens": sum(int(u.get(k, 0)) for k in ("input_tokens","output_tokens","cache_read_input_tokens","cache_creation_input_tokens")), "ok": out.returncode == 0}
|
|
25
|
+
if harness == "copilot-cli":
|
|
26
|
+
out = subprocess.run(["copilot", "-p", prompt, "--allow-all-tools"], cwd=workdir, capture_output=True, text=True, timeout=900)
|
|
27
|
+
return {"total_tokens": 0, "ok": out.returncode == 0} # token count from OTel export, not stdout
|
|
28
|
+
raise SystemExit(f"unknown harness {harness}")
|
|
29
|
+
|
|
30
|
+
GRADED_SUFFIXES = {".py", ".ts", ".tsx", ".js", ".go", ".java", ".rb", ".cs",
|
|
31
|
+
".yaml", ".yml", ".md"} # .md so SKILL.md manifests are gradeable
|
|
32
|
+
MAX_GRADED_BYTES = 512_000
|
|
33
|
+
LOG_CALL = re.compile(r"\b(?:logger|logging|log|LOG)\s*\.\s*"
|
|
34
|
+
r"(?:debug|info|warn|warning|error|critical|exception)\s*\(")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def extract_log_calls(content: str) -> list[str]:
|
|
38
|
+
"""The full argument list of each logging call, brackets matched.
|
|
39
|
+
|
|
40
|
+
Real log calls span several lines, so a line-bound regex misses exactly the
|
|
41
|
+
multi-line calls where a leaked field is most likely to hide. Matching
|
|
42
|
+
brackets here keeps the Rego declarative and precise instead of asking RE2
|
|
43
|
+
to approximate a parser.
|
|
44
|
+
"""
|
|
45
|
+
calls = []
|
|
46
|
+
for m in LOG_CALL.finditer(content):
|
|
47
|
+
depth, i = 0, m.end() - 1
|
|
48
|
+
while i < len(content):
|
|
49
|
+
if content[i] == "(":
|
|
50
|
+
depth += 1
|
|
51
|
+
elif content[i] == ")":
|
|
52
|
+
depth -= 1
|
|
53
|
+
if depth == 0:
|
|
54
|
+
calls.append(content[m.start():i + 1])
|
|
55
|
+
break
|
|
56
|
+
i += 1
|
|
57
|
+
else:
|
|
58
|
+
calls.append(content[m.start():]) # unbalanced: grade what we have
|
|
59
|
+
return calls
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def build_input(workdir: Path) -> dict:
|
|
63
|
+
"""The document the Rego policies evaluate.
|
|
64
|
+
|
|
65
|
+
`opa eval -i` takes a JSON file, never a directory, so the generated tree is
|
|
66
|
+
collected into {"files": [{path, content}]}. This is local grading of code the
|
|
67
|
+
eval itself produced; nothing here is emitted as telemetry (the eval record
|
|
68
|
+
carries booleans only).
|
|
69
|
+
"""
|
|
70
|
+
files = []
|
|
71
|
+
for f in sorted(workdir.rglob("*")):
|
|
72
|
+
if f.is_file() and f.suffix in GRADED_SUFFIXES and f.stat().st_size <= MAX_GRADED_BYTES:
|
|
73
|
+
content = f.read_text(encoding="utf-8", errors="replace")
|
|
74
|
+
files.append({"path": str(f.relative_to(workdir)),
|
|
75
|
+
"content": content,
|
|
76
|
+
"log_calls": extract_log_calls(content)})
|
|
77
|
+
return {"files": files}
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def grade(workdir: Path, policies: list[str], policy_root: Path, dry_run: bool) -> dict[str, bool]:
|
|
81
|
+
"""first-time policy pass per policy id. A policy that cannot be evaluated
|
|
82
|
+
fails loudly and counts as a failure — never a silent pass."""
|
|
83
|
+
if dry_run:
|
|
84
|
+
return {p: True for p in policies}
|
|
85
|
+
if not policy_root.is_dir():
|
|
86
|
+
raise SystemExit(f"policy root {policy_root} does not exist; run with --dry-run or add Rego packages")
|
|
87
|
+
results = {}
|
|
88
|
+
with tempfile.NamedTemporaryFile("w", suffix=".json", delete=False) as fh:
|
|
89
|
+
json.dump(build_input(workdir), fh)
|
|
90
|
+
input_path = fh.name
|
|
91
|
+
try:
|
|
92
|
+
for p in policies:
|
|
93
|
+
out = subprocess.run(
|
|
94
|
+
["opa", "eval", "-d", str(policy_root), "-i", input_path, f"data.{p}.deny", "--format", "json"],
|
|
95
|
+
capture_output=True, text=True)
|
|
96
|
+
if out.returncode != 0:
|
|
97
|
+
print(f"stdtel-eval: opa failed for {p}: {out.stderr.strip()}", file=sys.stderr)
|
|
98
|
+
results[p] = False
|
|
99
|
+
continue
|
|
100
|
+
try:
|
|
101
|
+
expressions = json.loads(out.stdout)["result"][0]["expressions"][0]["value"]
|
|
102
|
+
except (KeyError, IndexError, json.JSONDecodeError) as e:
|
|
103
|
+
print(f"stdtel-eval: cannot read opa output for {p}: {e}", file=sys.stderr)
|
|
104
|
+
results[p] = False
|
|
105
|
+
continue
|
|
106
|
+
results[p] = not expressions
|
|
107
|
+
finally:
|
|
108
|
+
os.unlink(input_path)
|
|
109
|
+
return results
|
|
110
|
+
|
|
111
|
+
def main(argv=None) -> int:
|
|
112
|
+
ap = argparse.ArgumentParser()
|
|
113
|
+
ap.add_argument("--harness", default="claude-code"); ap.add_argument("--tasks", default="eval/tasks.yaml")
|
|
114
|
+
ap.add_argument("--skills", default="skills"); ap.add_argument("--policies", default="policies")
|
|
115
|
+
ap.add_argument("--out", default="eval/results.jsonl"); ap.add_argument("--dry-run", action="store_true")
|
|
116
|
+
a = ap.parse_args(argv)
|
|
117
|
+
cat = load_catalogue(Path(a.skills)); tasks = yaml.safe_load(Path(a.tasks).read_text())
|
|
118
|
+
commit = subprocess.run(["git", "rev-parse", "--short", "HEAD"], capture_output=True, text=True).stdout.strip() or "unknown"
|
|
119
|
+
n = 0
|
|
120
|
+
with open(a.out, "a") as fh:
|
|
121
|
+
for t in tasks:
|
|
122
|
+
m = cat[t["skill"]]
|
|
123
|
+
for arm in ("without", "with"):
|
|
124
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
125
|
+
wd = Path(tmp)
|
|
126
|
+
fx = Path("eval") / t.get("fixture", "")
|
|
127
|
+
if fx.is_dir(): shutil.copytree(fx, wd, dirs_exist_ok=True)
|
|
128
|
+
t0 = time.time()
|
|
129
|
+
r = run_harness(a.harness, t["prompt"], wd, m.path.parent if arm == "with" else None, a.dry_run)
|
|
130
|
+
g = grade(wd, t["policies"], Path(a.policies), a.dry_run)
|
|
131
|
+
rec = {"eval_id": str(uuid.uuid4()), "task_id": t["id"], "arm": arm, "skill_name": m.name, "skill_version": m.version,
|
|
132
|
+
"harness": a.harness, "run_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), "passed": all(g.values()),
|
|
133
|
+
"policy_results": g, "total_tokens": r["total_tokens"], "duration_seconds": round(time.time() - t0, 2),
|
|
134
|
+
"catalogue_commit": commit}
|
|
135
|
+
fh.write(json.dumps(rec) + "\n"); n += 1
|
|
136
|
+
print(f"wrote {n} eval record(s) to {a.out}"); return 0
|
|
137
|
+
|
|
138
|
+
if __name__ == "__main__":
|
|
139
|
+
sys.exit(main())
|
stdtel/__init__.py
ADDED
stdtel/doctor.py
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
"""`stdtel doctor` — make a silently-broken install visible.
|
|
2
|
+
|
|
3
|
+
Hooks exit 0 so telemetry never blocks the developer. The cost of that rule is
|
|
4
|
+
that a broken install looks exactly like a working one: no spans and a healthy
|
|
5
|
+
session are indistinguishable from the outside. Four install-time failures in
|
|
6
|
+
this project's history were found by a person using it, none by its tests.
|
|
7
|
+
|
|
8
|
+
Every check reports a verdict, what was observed, and **the remedy** — the part a
|
|
9
|
+
diagnostic usually leaves out.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import os
|
|
14
|
+
import shutil
|
|
15
|
+
import subprocess
|
|
16
|
+
import sys
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
TIMEOUT_S = 3
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class Check:
|
|
25
|
+
name: str
|
|
26
|
+
ok: bool
|
|
27
|
+
detail: str
|
|
28
|
+
remedy: str = ""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _git_branch() -> str:
|
|
32
|
+
"""Current branch, or empty when git cannot answer."""
|
|
33
|
+
try:
|
|
34
|
+
r = subprocess.run(["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
|
35
|
+
capture_output=True, text=True, timeout=TIMEOUT_S)
|
|
36
|
+
return r.stdout.strip() if r.returncode == 0 else ""
|
|
37
|
+
except Exception: # noqa: BLE001 - not a git repo, no git, etc.
|
|
38
|
+
return ""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def hook_resolvable() -> Check:
|
|
42
|
+
"""Can a hook actually run `stdtel-hook`?
|
|
43
|
+
|
|
44
|
+
Checked the way a hook resolves it — a non-login `sh -c` — not the way an
|
|
45
|
+
interactive shell does. Shipping the bare name failed exactly here, with
|
|
46
|
+
"command not found" on every tool call (#13), while it resolved fine in a
|
|
47
|
+
terminal because a version manager had it on PATH.
|
|
48
|
+
"""
|
|
49
|
+
try:
|
|
50
|
+
# /bin/sh by absolute path: a diagnostic for a broken PATH must not need
|
|
51
|
+
# a working PATH to run, and /bin/sh is what a hook is given anyway
|
|
52
|
+
r = subprocess.run(["/bin/sh", "-c", "command -v stdtel-hook"],
|
|
53
|
+
capture_output=True, text=True, timeout=TIMEOUT_S)
|
|
54
|
+
found = r.stdout.strip()
|
|
55
|
+
except Exception as e: # noqa: BLE001 - diagnostics must not raise
|
|
56
|
+
return Check("hook resolvable", False, f"could not probe: {e}",
|
|
57
|
+
"check that `sh` is available")
|
|
58
|
+
if found:
|
|
59
|
+
return Check("hook resolvable", True, f"sh -c finds {found}")
|
|
60
|
+
which = shutil.which("stdtel-hook")
|
|
61
|
+
detail = ("not on the PATH a hook gets (sh -c); "
|
|
62
|
+
+ (f"an interactive shell finds {which}" if which else "not on this shell's PATH either"))
|
|
63
|
+
return Check("hook resolvable", False, detail,
|
|
64
|
+
"uv tool install stdtel, then `stdtel-install settings` to write an absolute path")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def hooks_registered() -> Check:
|
|
68
|
+
"""Registered once, in exactly one place.
|
|
69
|
+
|
|
70
|
+
Plugin hooks and settings hooks do not deduplicate against each other, so
|
|
71
|
+
both firing means every window is counted twice.
|
|
72
|
+
"""
|
|
73
|
+
import json
|
|
74
|
+
settings = Path.home() / ".claude" / "settings.json"
|
|
75
|
+
in_settings = False
|
|
76
|
+
if settings.is_file():
|
|
77
|
+
try:
|
|
78
|
+
# Look for OUR hooks specifically. Testing `bool(hooks)` reported
|
|
79
|
+
# "registered in settings AND as a plugin" to anyone with hooks of
|
|
80
|
+
# their own — a confidently wrong warning, in the tool meant to
|
|
81
|
+
# diagnose confidently wrong behaviour.
|
|
82
|
+
raw = json.loads(settings.read_text() or "{}").get("hooks") or {}
|
|
83
|
+
in_settings = "stdtel-hook" in json.dumps(raw)
|
|
84
|
+
except json.JSONDecodeError:
|
|
85
|
+
return Check("hooks registered", False, f"{settings} is not valid JSON",
|
|
86
|
+
"fix or remove the file, then run `stdtel-install settings`")
|
|
87
|
+
plugin = (Path.home() / ".claude" / "plugins" / "installed_plugins.json")
|
|
88
|
+
in_plugin = plugin.is_file() and "stdtel" in plugin.read_text()
|
|
89
|
+
if in_settings and in_plugin:
|
|
90
|
+
return Check("hooks registered", False, "registered in settings AND as a plugin",
|
|
91
|
+
"remove the hooks block from ~/.claude/settings.json; plugin hooks and settings "
|
|
92
|
+
"hooks both fire, double-counting every skill window")
|
|
93
|
+
if in_settings or in_plugin:
|
|
94
|
+
return Check("hooks registered", True,
|
|
95
|
+
"via settings.json" if in_settings else "via the plugin")
|
|
96
|
+
return Check("hooks registered", False, "no stdtel hooks found",
|
|
97
|
+
"run `stdtel-install settings`, or install the plugin")
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def ticket_key() -> Check:
|
|
101
|
+
"""Does this branch yield a ticket key?
|
|
102
|
+
|
|
103
|
+
Only fixable now: a branch renamed tomorrow does not retroactively attribute
|
|
104
|
+
today's work, and unattributed sessions are excluded from outcome analysis.
|
|
105
|
+
"""
|
|
106
|
+
from stdtel.enrich import ticket_from_branch
|
|
107
|
+
branch = os.environ.get("STDTEL_BRANCH") or _git_branch()
|
|
108
|
+
key = ticket_from_branch(branch or "")
|
|
109
|
+
if key == "unattributed":
|
|
110
|
+
return Check("ticket key", False,
|
|
111
|
+
f"branch {branch or '(unknown)'!r} yields 'unattributed'",
|
|
112
|
+
"rename the branch to carry a ticket key, e.g. feature/PLAT-42-thing — "
|
|
113
|
+
"this work is excluded from outcome analysis until it does")
|
|
114
|
+
return Check("ticket key", True, f"{branch} -> {key}")
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def catalogue_ok() -> Check:
|
|
118
|
+
"""Can the catalogue be found, and does it contain anything?
|
|
119
|
+
|
|
120
|
+
An empty or unfindable catalogue does not fail loudly: skills are simply
|
|
121
|
+
recorded as `unversioned`, with no standard_id and no policy_ids.
|
|
122
|
+
"""
|
|
123
|
+
from stdtel.hooks.cli import _catalogue, skills_roots
|
|
124
|
+
roots = skills_roots()
|
|
125
|
+
cat = _catalogue()
|
|
126
|
+
if not roots:
|
|
127
|
+
return Check("skill catalogue", False, "no catalogue root exists",
|
|
128
|
+
"symlink skills into ~/.claude/skills, or set STDTEL_SKILLS_ROOT")
|
|
129
|
+
if not cat:
|
|
130
|
+
return Check("skill catalogue", False,
|
|
131
|
+
f"{len(roots)} root(s) searched, 0 skills found",
|
|
132
|
+
"skills will record as `unversioned` with no standard_id; check "
|
|
133
|
+
"STDTEL_SKILLS_ROOT and run `stdtel-validate` on it")
|
|
134
|
+
return Check("skill catalogue", True, f"{len(cat)} skill(s) across {len(roots)} root(s)")
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def collector_ok() -> Check:
|
|
138
|
+
"""Is anything listening where spans are being sent?
|
|
139
|
+
|
|
140
|
+
A dead collector is the quietest failure of all: the exporter times out and
|
|
141
|
+
drops, leaving one stderr line no editor shows.
|
|
142
|
+
"""
|
|
143
|
+
import urllib.error
|
|
144
|
+
import urllib.request
|
|
145
|
+
from stdtel.exporter import _endpoint
|
|
146
|
+
endpoint = _endpoint()
|
|
147
|
+
req = urllib.request.Request(endpoint, data=b"{}", method="POST",
|
|
148
|
+
headers={"content-type": "application/json"})
|
|
149
|
+
try:
|
|
150
|
+
urllib.request.urlopen(req, timeout=TIMEOUT_S)
|
|
151
|
+
return Check("collector reachable", True, f"{endpoint} answered")
|
|
152
|
+
except urllib.error.HTTPError:
|
|
153
|
+
# any HTTP status means something is listening and speaking OTLP
|
|
154
|
+
return Check("collector reachable", True, f"{endpoint} answered")
|
|
155
|
+
except Exception as e: # noqa: BLE001
|
|
156
|
+
return Check("collector reachable", False, f"{endpoint}: {type(e).__name__}",
|
|
157
|
+
"spans are being dropped right now. Start the stack with `make up`, or point "
|
|
158
|
+
"STDTEL_OTLP_ENDPOINT at a live collector (OTEL_* cannot reach a hook)")
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def recent_state() -> Check:
|
|
162
|
+
"""Has a hook actually run recently? Registration alone proves nothing."""
|
|
163
|
+
from stdtel.state import state_dir
|
|
164
|
+
try:
|
|
165
|
+
files = sorted(state_dir().glob("*.json"), key=lambda p: p.stat().st_mtime, reverse=True)
|
|
166
|
+
except Exception as e: # noqa: BLE001
|
|
167
|
+
return Check("hooks running", False, f"cannot read state dir: {e}", "check STDTEL_STATE_DIR")
|
|
168
|
+
if not files:
|
|
169
|
+
return Check("hooks running", False, "no session state has ever been written",
|
|
170
|
+
"the hooks are registered but not firing; run `stdtel-install where` and "
|
|
171
|
+
"invoke stdtel-hook by hand to see the error")
|
|
172
|
+
import datetime as dt
|
|
173
|
+
newest = dt.datetime.fromtimestamp(files[0].stat().st_mtime)
|
|
174
|
+
return Check("hooks running", True, f"last session state {newest:%Y-%m-%d %H:%M}")
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
CHECKS = (hook_resolvable, hooks_registered, ticket_key, catalogue_ok, collector_ok, recent_state)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def check_all() -> list[Check]:
|
|
181
|
+
out = []
|
|
182
|
+
for fn in CHECKS:
|
|
183
|
+
try:
|
|
184
|
+
out.append(fn())
|
|
185
|
+
except Exception as e: # noqa: BLE001 - a diagnostic must never crash
|
|
186
|
+
out.append(Check(fn.__name__, False, f"check itself failed: {e}",
|
|
187
|
+
"this is a bug in stdtel doctor"))
|
|
188
|
+
return out
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def main(argv: list[str] | None = None) -> int:
|
|
192
|
+
import argparse
|
|
193
|
+
ap = argparse.ArgumentParser(prog="stdtel-doctor", description=__doc__.splitlines()[0])
|
|
194
|
+
ap.add_argument("--quiet", "-q", action="store_true", help="only show problems")
|
|
195
|
+
a = ap.parse_args(sys.argv[1:] if argv is None else argv)
|
|
196
|
+
|
|
197
|
+
checks = check_all()
|
|
198
|
+
failed = [c for c in checks if not c.ok]
|
|
199
|
+
for c in checks:
|
|
200
|
+
if c.ok and a.quiet:
|
|
201
|
+
continue
|
|
202
|
+
mark = "ok " if c.ok else "FAIL"
|
|
203
|
+
print(f" {mark} {c.name:<20} {c.detail}")
|
|
204
|
+
if not c.ok and c.remedy:
|
|
205
|
+
print(f" -> {c.remedy}")
|
|
206
|
+
print(f"\n{len(checks) - len(failed)} passed, {len(failed)} failed")
|
|
207
|
+
return 1 if failed else 0
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
if __name__ == "__main__":
|
|
211
|
+
raise SystemExit(main())
|
stdtel/enrich.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Resource enrichment: join keys derived from git (ticket id, repo, team)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
import subprocess
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
TICKET = re.compile(r"\b([A-Z][A-Z0-9]{1,9}-\d{1,6})\b")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _git(args: list[str], cwd: Path | None) -> str:
|
|
13
|
+
try:
|
|
14
|
+
return subprocess.check_output(["git", *args], cwd=cwd, stderr=subprocess.DEVNULL, text=True).strip()
|
|
15
|
+
except Exception:
|
|
16
|
+
return ""
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def ticket_from_branch(branch: str) -> str:
|
|
20
|
+
m = TICKET.search(branch.upper())
|
|
21
|
+
return m.group(1) if m else "unattributed"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
# Copilot's native hook dialect is camelCase; its "VS Code compatible" dialect
|
|
25
|
+
# is snake_case and indistinguishable from Claude Code's. These keys are only
|
|
26
|
+
# positive evidence of Copilot, never of Claude Code.
|
|
27
|
+
COPILOT_ONLY_KEYS = (("sessionId", "session_id"), ("toolName", "tool_name"),
|
|
28
|
+
("transcriptPath", "transcript_path"))
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def detect_harness(payload: dict | None = None) -> str | None:
|
|
32
|
+
"""Harness inferred from the hook payload, or None when it cannot be told.
|
|
33
|
+
|
|
34
|
+
VS Code Copilot reads `~/.claude/settings.json` and `.claude/settings.json`
|
|
35
|
+
for hooks, so Copilot events arrive through hooks registered for Claude Code
|
|
36
|
+
and would otherwise be stamped `std.harness=claude-code` — silently
|
|
37
|
+
corrupting the cross-harness comparison this project exists to make.
|
|
38
|
+
camelCase keys prove Copilot. The snake_case dialect cannot be told apart
|
|
39
|
+
from the payload alone: set STDTEL_HARNESS in that hook's own `env` block.
|
|
40
|
+
"""
|
|
41
|
+
if not payload:
|
|
42
|
+
return None
|
|
43
|
+
for camel, snake in COPILOT_ONLY_KEYS:
|
|
44
|
+
if camel in payload and snake not in payload:
|
|
45
|
+
return "copilot" # vscode vs cli needs STDTEL_HARNESS to say
|
|
46
|
+
return None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def resource_attributes(cwd: Path | None = None, payload: dict | None = None) -> dict:
|
|
50
|
+
branch = os.environ.get("STDTEL_BRANCH") or _git(["rev-parse", "--abbrev-ref", "HEAD"], cwd)
|
|
51
|
+
remote = os.environ.get("STDTEL_REPO") or _git(["config", "--get", "remote.origin.url"], cwd)
|
|
52
|
+
repo = re.sub(r"\.git$", "", remote.rsplit("/", 1)[-1]) if remote else "unknown"
|
|
53
|
+
return {
|
|
54
|
+
"std.ticket.id": ticket_from_branch(branch or ""),
|
|
55
|
+
"std.repo": repo,
|
|
56
|
+
"std.team": os.environ.get("STDTEL_TEAM", "unknown"),
|
|
57
|
+
"std.harness": detect_harness(payload) or os.environ.get("STDTEL_HARNESS", "claude-code"),
|
|
58
|
+
"std.harness.mode": os.environ.get("STDTEL_HARNESS_MODE", "agent"),
|
|
59
|
+
}
|