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 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
@@ -0,0 +1,2 @@
1
+ """stdtel — standards-as-skills telemetry."""
2
+ __version__ = "0.1.0"
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
+ }