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.
stdtel/manifest.py ADDED
@@ -0,0 +1,217 @@
1
+ """Parse and validate SKILL.md front-matter (the standards-repo contract)."""
2
+ from __future__ import annotations
3
+
4
+ import os
5
+ import re
6
+ import sys
7
+ from dataclasses import dataclass, field
8
+ from pathlib import Path
9
+
10
+ import yaml
11
+
12
+ SEMVER = re.compile(r"^\d+\.\d+\.\d+$")
13
+ STANDARD_ID = re.compile(r"^STD-[A-Z]+-\d{3}$")
14
+ HARNESSES = {"claude-code", "copilot-vscode", "copilot-cli"}
15
+ SUCCESS_SIGNALS = {"policy", "test", "manual"}
16
+
17
+
18
+ @dataclass
19
+ class SkillManifest:
20
+ name: str
21
+ version: str
22
+ standard_id: str
23
+ policy_ids: list[str]
24
+ owner: str
25
+ harness_support: list[str]
26
+ telemetry_emit: bool = True
27
+ success_signal: str = "policy"
28
+ path: Path | None = None
29
+ extra: dict = field(default_factory=dict)
30
+
31
+ def as_attributes(self) -> dict:
32
+ """Span attributes contributed by the manifest (std.* namespace)."""
33
+ return {
34
+ "std.skill.name": self.name,
35
+ "std.skill.version": self.version,
36
+ "std.standard_id": self.standard_id,
37
+ "std.policy.ids": ",".join(self.policy_ids),
38
+ "std.skill.owner": self.owner,
39
+ }
40
+
41
+
42
+ class ManifestError(ValueError):
43
+ pass
44
+
45
+
46
+ def split_front_matter(text: str) -> tuple[dict, str]:
47
+ if not text.startswith("---"):
48
+ raise ManifestError("SKILL.md must start with YAML front-matter (---)")
49
+ parts = text.split("---", 2)
50
+ if len(parts) < 3:
51
+ raise ManifestError("unterminated front-matter")
52
+ data = yaml.safe_load(parts[1]) or {}
53
+ return data, parts[2]
54
+
55
+
56
+ # Agent Skills permits only these six frontmatter keys; everything else is a
57
+ # client-only extension that hard-errors on claude.ai upload. Our contract fields
58
+ # therefore live under `metadata:`, which the spec types as a string->string map,
59
+ # so lists arrive comma-separated.
60
+ SPEC_KEYS = {"name", "description", "license", "compatibility", "metadata", "allowed-tools"}
61
+ CONTRACT_KEYS = ("version", "standard_id", "policy_ids", "owner", "harness_support", "telemetry")
62
+
63
+
64
+ def _split_list(value) -> list[str]:
65
+ """`policy_ids` as a real list (top-level form) or a comma-separated string
66
+ (spec form, where metadata values must be strings)."""
67
+ if isinstance(value, str):
68
+ return [v.strip() for v in value.split(",") if v.strip()]
69
+ return value
70
+
71
+
72
+ def _flatten(data: dict) -> dict:
73
+ """Contract fields, wherever they live.
74
+
75
+ Spec-conformant SKILL.md nests them under `metadata:`; the original flat form
76
+ is still accepted so existing catalogues keep validating. `metadata:` wins.
77
+ """
78
+ meta = data.get("metadata") or {}
79
+ if not isinstance(meta, dict):
80
+ meta = {}
81
+ out = dict(data)
82
+ for key in CONTRACT_KEYS:
83
+ if key in meta:
84
+ out[key] = meta[key]
85
+ if "telemetry" not in out:
86
+ # spec form flattens the nested telemetry block into dotted metadata keys
87
+ tel = {k.split(".", 1)[1]: v for k, v in meta.items() if k.startswith("telemetry.")}
88
+ if tel:
89
+ out["telemetry"] = tel
90
+ for key in ("policy_ids", "harness_support"):
91
+ if key in out:
92
+ out[key] = _split_list(out[key])
93
+ return out
94
+
95
+
96
+ def parse_manifest(text: str, path: Path | None = None) -> SkillManifest:
97
+ data, _ = split_front_matter(text)
98
+ data = _flatten(data)
99
+ errors = []
100
+ for key in ("name", "version", "standard_id", "policy_ids", "owner", "harness_support"):
101
+ if key not in data:
102
+ errors.append(f"missing required field: {key}")
103
+ if errors:
104
+ raise ManifestError("; ".join(errors))
105
+ if not SEMVER.match(str(data["version"])):
106
+ errors.append(f"version must be semver, got {data['version']!r}")
107
+ if not STANDARD_ID.match(data["standard_id"]):
108
+ errors.append(f"standard_id must match STD-XXX-000, got {data['standard_id']!r}")
109
+ if not isinstance(data["policy_ids"], list) or not data["policy_ids"]:
110
+ errors.append("policy_ids must be a non-empty list")
111
+ bad = set(data["harness_support"]) - HARNESSES
112
+ if bad:
113
+ errors.append(f"unknown harness(es): {sorted(bad)}")
114
+ tel = data.get("telemetry", {}) or {}
115
+ if not isinstance(tel, dict):
116
+ tel = {}
117
+ signal = str(tel.get("success_signal", "policy"))
118
+ if signal not in SUCCESS_SIGNALS:
119
+ errors.append(f"telemetry.success_signal must be one of {sorted(SUCCESS_SIGNALS)}")
120
+ if errors:
121
+ raise ManifestError("; ".join(errors))
122
+ known = SPEC_KEYS | set(CONTRACT_KEYS) | {"description"}
123
+ return SkillManifest(
124
+ name=data["name"],
125
+ version=str(data["version"]),
126
+ standard_id=data["standard_id"],
127
+ policy_ids=list(data["policy_ids"]),
128
+ owner=data["owner"],
129
+ harness_support=list(data["harness_support"]),
130
+ telemetry_emit=str(tel.get("emit", True)).lower() not in ("false", "0", "no"),
131
+ success_signal=signal,
132
+ path=path,
133
+ extra={k: v for k, v in data.items() if k not in known},
134
+ )
135
+
136
+
137
+ def load_manifest(path: Path) -> SkillManifest:
138
+ return parse_manifest(path.read_text(encoding="utf-8"), path=path)
139
+
140
+
141
+ def iter_skill_files(root: Path) -> list[Path]:
142
+ """Every SKILL.md under root, sorted, following symlinked directories.
143
+
144
+ Catalogues are assembled by symlinking skills into a shared directory
145
+ (~/.claude/skills), which Path.rglob would silently refuse to descend into.
146
+ Each resolved directory is visited once, so a symlink cycle cannot hang a hook.
147
+ """
148
+ found, seen = [], set()
149
+ for dirpath, dirnames, filenames in os.walk(root, followlinks=True):
150
+ real = os.path.realpath(dirpath)
151
+ if real in seen:
152
+ dirnames[:] = [] # cycle, or a second route to the same tree
153
+ continue
154
+ seen.add(real)
155
+ if "SKILL.md" in filenames:
156
+ found.append(Path(dirpath) / "SKILL.md")
157
+ return sorted(found)
158
+
159
+
160
+ def load_catalogue(root: Path, strict: bool = True) -> dict[str, SkillManifest]:
161
+ """All SKILL.md files under root, keyed by skill name.
162
+
163
+ `strict` is the CI contract gate: any invalid or duplicate manifest raises.
164
+ Hooks pass strict=False so one broken SKILL.md in a shared skills directory
165
+ cannot silence telemetry for every other skill (first definition wins).
166
+ """
167
+ out: dict[str, SkillManifest] = {}
168
+ for p in iter_skill_files(root):
169
+ try:
170
+ m = load_manifest(p)
171
+ except (ManifestError, OSError, yaml.YAMLError):
172
+ if strict:
173
+ raise
174
+ continue
175
+ if m.name in out:
176
+ if strict:
177
+ raise ManifestError(f"duplicate skill name {m.name!r}: {p} and {out[m.name].path}")
178
+ continue
179
+ out[m.name] = m
180
+ return out
181
+
182
+
183
+ def cli(argv: list[str] | None = None) -> int:
184
+ """The CI contract gate.
185
+
186
+ Exit 0 valid, 1 an invalid manifest, 2 the root cannot be read. That last
187
+ case used to exit 0 with "0 skill(s) valid" — a typo'd path in CI reported a
188
+ clean gate over nothing, and `--help` was parsed as a directory name.
189
+ """
190
+ import argparse
191
+
192
+ ap = argparse.ArgumentParser(
193
+ prog="stdtel-validate",
194
+ description="Validate SKILL.md front-matter against the standards contract.")
195
+ ap.add_argument("root", nargs="?", default="skills",
196
+ help="directory to scan recursively for SKILL.md (default: skills)")
197
+ ap.add_argument("--quiet", "-q", action="store_true", help="only report failures")
198
+ a = ap.parse_args(sys.argv[1:] if argv is None else argv)
199
+
200
+ root = Path(a.root).expanduser()
201
+ if not root.is_dir():
202
+ print(f"stdtel-validate: no such directory: {root}", file=sys.stderr)
203
+ return 2
204
+ try:
205
+ cat = load_catalogue(root)
206
+ except ManifestError as e:
207
+ print(f"INVALID: {e}", file=sys.stderr)
208
+ return 1
209
+ if not a.quiet:
210
+ for name, m in cat.items():
211
+ print(f"OK {name}@{m.version} {m.standard_id} policies={len(m.policy_ids)}")
212
+ print(f"{len(cat)} skill(s) valid in {root}")
213
+ return 0
214
+
215
+
216
+ if __name__ == "__main__":
217
+ raise SystemExit(cli())
@@ -0,0 +1,126 @@
1
+ """Grade a PR's tree with OPA and emit `policy_result` rows.
2
+
3
+ The primary effectiveness metric is *first-time* OPA policy pass rate, so
4
+ `run_seq` — which CI run this was — is the metric, not metadata. It cannot be
5
+ recovered afterwards: re-runs, retries and cancelled jobs make the true ordering
6
+ unknowable from history, which is why this runs in CI and writes an artefact
7
+ rather than being reconstructed by the loader (ADR-002).
8
+
9
+ stdtel-policy-report --pr-id owner/repo#42 --run-seq 1 --out policy.jsonl
10
+
11
+ Grading reuses `eval.run_eval`, so CI and the offline eval runner cannot diverge
12
+ on what "passing" means.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import argparse
17
+ import datetime as dt
18
+ import json
19
+ import subprocess
20
+ import sys
21
+ from pathlib import Path
22
+
23
+ COLUMNS = ("pr_id", "policy_id", "run_seq", "passed", "evaluated_at")
24
+
25
+
26
+ def next_run_seq(previous_runs: int) -> int:
27
+ """1 for the first CI run on a PR, 2 for the next, and so on.
28
+
29
+ Derived from a count of prior completed runs rather than a retry counter:
30
+ `run_attempt` counts re-runs of one workflow run, not runs of the workflow.
31
+ """
32
+ if previous_runs < 0:
33
+ raise ValueError(f"previous_runs cannot be negative: {previous_runs}")
34
+ return previous_runs + 1
35
+
36
+
37
+ def count_previous_runs(repo: str, workflow: str, branch: str) -> int:
38
+ """Completed runs of this workflow on this branch, via `gh`.
39
+
40
+ Returns 0 when it cannot tell. That biases toward calling a run "first",
41
+ which is the safer error: a duplicated run_seq=1 is visible as a conflict in
42
+ the warehouse, whereas silently skipping to 2 would lose the first-time
43
+ measurement entirely.
44
+ """
45
+ out = subprocess.run(
46
+ ["gh", "api", f"repos/{repo}/actions/workflows/{workflow}/runs",
47
+ "-f", f"branch={branch}", "-f", "status=completed", "--jq", ".total_count"],
48
+ capture_output=True, text=True)
49
+ if out.returncode != 0:
50
+ print(f"stdtel-policy-report: cannot count prior runs ({out.stderr.strip()}); "
51
+ f"treating this as the first", file=sys.stderr)
52
+ return 0
53
+ try:
54
+ return int(out.stdout.strip())
55
+ except ValueError:
56
+ return 0
57
+
58
+
59
+ def build_rows(workdir: Path, policies: list[str], policy_root: Path,
60
+ pr_id: str, run_seq: int) -> list[dict]:
61
+ """One row per policy. A policy that cannot be evaluated fails; it is never
62
+ omitted, because an absent row and a failing row must not look alike."""
63
+ from eval.run_eval import grade
64
+
65
+ evaluated_at = dt.datetime.now(dt.timezone.utc).isoformat().replace("+00:00", "Z")
66
+ results = grade(Path(workdir), policies, Path(policy_root), dry_run=False)
67
+ return [{"pr_id": pr_id, "policy_id": policy_id, "run_seq": run_seq,
68
+ "passed": bool(passed), "evaluated_at": evaluated_at}
69
+ for policy_id, passed in results.items()]
70
+
71
+
72
+ def write_jsonl(path: Path, rows: list[dict]) -> int:
73
+ path.parent.mkdir(parents=True, exist_ok=True)
74
+ with path.open("w", encoding="utf-8") as fh:
75
+ for row in rows:
76
+ fh.write(json.dumps({c: row[c] for c in COLUMNS}) + "\n")
77
+ return len(rows)
78
+
79
+
80
+ def main(argv: list[str] | None = None) -> int:
81
+ ap = argparse.ArgumentParser(prog="stdtel-policy-report", description=__doc__.splitlines()[0])
82
+ ap.add_argument("--pr-id", required=True, help="owner/repo#number, matching pull_request.pr_id")
83
+ ap.add_argument("--policies", type=Path, default=Path("policies"))
84
+ ap.add_argument("--policy-ids", help="comma-separated; defaults to every package under --policies")
85
+ ap.add_argument("--workdir", type=Path, default=Path("."))
86
+ ap.add_argument("--out", type=Path, default=Path("policy-results.jsonl"))
87
+ run = ap.add_mutually_exclusive_group(required=True)
88
+ run.add_argument("--run-seq", type=int, help="explicit sequence number")
89
+ run.add_argument("--derive-run-seq", nargs=3, metavar=("REPO", "WORKFLOW", "BRANCH"),
90
+ help="count prior completed runs with gh and use the next number")
91
+ a = ap.parse_args(sys.argv[1:] if argv is None else argv)
92
+
93
+ if not a.policies.is_dir():
94
+ print(f"stdtel-policy-report: no policy root at {a.policies}", file=sys.stderr)
95
+ return 2
96
+
97
+ if a.policy_ids:
98
+ policy_ids = [p.strip() for p in a.policy_ids.split(",") if p.strip()]
99
+ else:
100
+ policy_ids = discover_policy_ids(a.policies)
101
+ if not policy_ids:
102
+ print(f"stdtel-policy-report: no policies found under {a.policies}", file=sys.stderr)
103
+ return 2
104
+
105
+ run_seq = a.run_seq if a.run_seq is not None else next_run_seq(count_previous_runs(*a.derive_run_seq))
106
+ rows = build_rows(a.workdir, policy_ids, a.policies, a.pr_id, run_seq)
107
+ write_jsonl(a.out, rows)
108
+ failed = [r["policy_id"] for r in rows if not r["passed"]]
109
+ print(f"wrote {len(rows)} policy result(s) to {a.out} (run_seq={run_seq}); "
110
+ f"{'failed: ' + ', '.join(failed) if failed else 'all passed'}")
111
+ return 0
112
+
113
+
114
+ def discover_policy_ids(policy_root: Path) -> list[str]:
115
+ """Rego `package` declarations under the root, which are the policy ids."""
116
+ import re
117
+ ids = set()
118
+ for rego in sorted(Path(policy_root).rglob("*.rego")):
119
+ m = re.search(r"^package\s+([\w.]+)", rego.read_text(), re.M)
120
+ if m:
121
+ ids.add(m.group(1))
122
+ return sorted(ids)
123
+
124
+
125
+ if __name__ == "__main__":
126
+ raise SystemExit(main())
stdtel/skillmap.py ADDED
@@ -0,0 +1,15 @@
1
+ """Generate the Copilot skill lookup table from the skills catalogue."""
2
+ from __future__ import annotations
3
+ import sys
4
+ from pathlib import Path
5
+ import yaml
6
+ from stdtel.manifest import load_catalogue
7
+
8
+ def generate(root: Path) -> str:
9
+ cat = load_catalogue(root)
10
+ table = {n: {"standard_id": m.standard_id, "version": m.version, "policy_ids": ",".join(m.policy_ids)}
11
+ for n, m in cat.items()}
12
+ return yaml.safe_dump(table, sort_keys=True)
13
+
14
+ if __name__ == "__main__":
15
+ print(generate(Path(sys.argv[1] if len(sys.argv) > 1 else "skills")), end="")
stdtel/spool.py ADDED
@@ -0,0 +1,124 @@
1
+ """Append spans to disk; export them from somewhere else (ADR-008).
2
+
3
+ A hook that never opens a socket cannot stall on one. That is what "hooks never
4
+ block the developer" was always reaching for — bounding the export timeout only
5
+ traded a 7.34s stall for silent data loss.
6
+
7
+ The file is NDJSON, one span per line, under `~/.stdtel/spool/`. `scrub()` runs
8
+ before anything is written: the content rules apply to disk, not only to the wire.
9
+
10
+ Draining is deliberately conservative. Records are removed only after the export
11
+ returns, so a failure leaves everything in place, and the drain rewrites only what
12
+ it actually read — a hook firing mid-drain is not lost.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ import os
18
+ import tempfile
19
+ from pathlib import Path
20
+ from typing import Callable, Iterable
21
+
22
+ DEFAULT_MAX_RECORDS = 50_000
23
+
24
+
25
+ class SpoolFull(RuntimeError):
26
+ pass
27
+
28
+
29
+ def spool_dir() -> Path:
30
+ d = Path(os.environ.get("STDTEL_SPOOL_DIR", Path.home() / ".stdtel" / "spool"))
31
+ d.mkdir(parents=True, exist_ok=True)
32
+ return d
33
+
34
+
35
+ def spool_path() -> Path:
36
+ return spool_dir() / "spans.ndjson"
37
+
38
+
39
+ def append(records: Iterable[dict]) -> int:
40
+ """Write records and return. No network, no retry, no blocking."""
41
+ from stdtel.exporter import scrub
42
+
43
+ rows = []
44
+ for r in records:
45
+ row = dict(r)
46
+ row["attributes"] = scrub(dict(row.get("attributes") or {}))
47
+ row["resource"] = scrub(dict(row.get("resource") or {}))
48
+ rows.append(row)
49
+ if not rows:
50
+ return 0
51
+ with spool_path().open("a", encoding="utf-8") as fh:
52
+ for row in rows:
53
+ fh.write(json.dumps(row) + "\n")
54
+ return len(rows)
55
+
56
+
57
+ def read_all() -> list[dict]:
58
+ """Every readable record. A corrupt line is skipped, not fatal — one bad
59
+ write must not strand everything behind it."""
60
+ path = spool_path()
61
+ if not path.is_file():
62
+ return []
63
+ out = []
64
+ for line in path.read_text(encoding="utf-8", errors="replace").splitlines():
65
+ if not line.strip():
66
+ continue
67
+ try:
68
+ out.append(json.loads(line))
69
+ except json.JSONDecodeError:
70
+ continue
71
+ return out
72
+
73
+
74
+ def _rewrite(rows: list[dict]) -> None:
75
+ """Replace the spool atomically, so a crash mid-rewrite cannot truncate it."""
76
+ path = spool_path()
77
+ fd, tmp = tempfile.mkstemp(dir=str(path.parent), suffix=".tmp")
78
+ try:
79
+ with os.fdopen(fd, "w", encoding="utf-8") as fh:
80
+ for row in rows:
81
+ fh.write(json.dumps(row) + "\n")
82
+ os.replace(tmp, path)
83
+ except Exception:
84
+ Path(tmp).unlink(missing_ok=True)
85
+ raise
86
+
87
+
88
+ def drain(export: Callable[[list[dict]], None]) -> int:
89
+ """Hand every spooled record to `export`, then remove exactly those.
90
+
91
+ Records appended while `export` runs are kept: the rewrite drops only the
92
+ records that were read, matched by position, rather than truncating the file.
93
+ A raising export removes nothing.
94
+ """
95
+ batch = read_all()
96
+ if not batch:
97
+ return 0
98
+ export(batch) # may raise; nothing is removed if it does
99
+ remaining = read_all()[len(batch):]
100
+ _rewrite(remaining)
101
+ return len(batch)
102
+
103
+
104
+ def max_records_default() -> int:
105
+ try:
106
+ return max(1, int(os.environ.get("STDTEL_SPOOL_MAX", DEFAULT_MAX_RECORDS)))
107
+ except ValueError:
108
+ return DEFAULT_MAX_RECORDS
109
+
110
+
111
+ def trim(max_records: int | None = None) -> int:
112
+ """Enforce the bound, oldest first. Returns how many were dropped.
113
+
114
+ The count is the point: an unbounded spool fills a developer's disk, and a
115
+ silent drop at the bound would reintroduce exactly the invisible loss this
116
+ design removes.
117
+ """
118
+ max_records = max_records_default() if max_records is None else max_records
119
+ rows = read_all()
120
+ if len(rows) <= max_records:
121
+ return 0
122
+ dropped = len(rows) - max_records
123
+ _rewrite(rows[dropped:])
124
+ return dropped
stdtel/spool_export.py ADDED
@@ -0,0 +1,86 @@
1
+ """Drain the spool and export it (ADR-008).
2
+
3
+ The hook writes NDJSON and returns; this turns those records back into spans and
4
+ sends them. Run it on a schedule, from `make up`, or with `--watch`.
5
+
6
+ stdtel-export --once
7
+ stdtel-export --watch --interval 30
8
+
9
+ Export failure leaves the spool untouched, so the next run retries. That is the
10
+ whole point: capture keeps working while the collector does not.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import argparse
15
+ import sys
16
+ import time
17
+
18
+ DEFAULT_INTERVAL_S = 30
19
+
20
+
21
+ def export_batch(batch: list[dict], exporter=None) -> int:
22
+ """Rebuild spans from spooled records and emit them.
23
+
24
+ Grouped by resource so each group carries the session's own attributes;
25
+ records from different sessions must not be merged under one resource.
26
+ """
27
+ from stdtel.exporter import SESSION_SPAN_NAME, SPAN_NAME, build_provider, emit_invocations, emit_session_cost
28
+
29
+ groups: dict[tuple, list[dict]] = {}
30
+ for row in batch:
31
+ key = tuple(sorted((row.get("resource") or {}).items()))
32
+ groups.setdefault(key, []).append(row)
33
+
34
+ total = 0
35
+ for key, rows in groups.items():
36
+ provider = build_provider(dict(key), exporter=exporter)
37
+ invocations = [r for r in rows if r.get("name") == SPAN_NAME]
38
+ if invocations:
39
+ by_session: dict[str, list[dict]] = {}
40
+ for r in invocations:
41
+ by_session.setdefault(r.get("session_id", ""), []).append(r)
42
+ for session_id, rs in by_session.items():
43
+ total += emit_invocations(provider, rs, session_id)
44
+ for r in rows:
45
+ if r.get("name") == SESSION_SPAN_NAME:
46
+ total += emit_session_cost(provider, r.get("attributes") or {},
47
+ r.get("session_id", ""),
48
+ r.get("started_at", 0.0), r.get("ended_at", 0.0))
49
+ return total
50
+
51
+
52
+ def run_once() -> int:
53
+ from stdtel.spool import drain, trim
54
+ dropped = trim()
55
+ if dropped:
56
+ print(f"stdtel-export: spool over its bound, dropped {dropped} oldest record(s)",
57
+ file=sys.stderr)
58
+ try:
59
+ return drain(export_batch)
60
+ except Exception as e: # noqa: BLE001 - leave the spool intact
61
+ print(f"stdtel-export: export failed, spool kept for retry: {e}", file=sys.stderr)
62
+ return -1
63
+
64
+
65
+ def main(argv: list[str] | None = None) -> int:
66
+ ap = argparse.ArgumentParser(prog="stdtel-export", description=__doc__.splitlines()[0])
67
+ mode = ap.add_mutually_exclusive_group()
68
+ mode.add_argument("--once", action="store_true", help="drain and exit (default)")
69
+ mode.add_argument("--watch", action="store_true", help="drain repeatedly")
70
+ ap.add_argument("--interval", type=int, default=DEFAULT_INTERVAL_S)
71
+ a = ap.parse_args(sys.argv[1:] if argv is None else argv)
72
+
73
+ if not a.watch:
74
+ n = run_once()
75
+ if n >= 0:
76
+ print(f"exported {n} span(s)")
77
+ return 0 if n >= 0 else 1
78
+ while True:
79
+ n = run_once()
80
+ if n > 0:
81
+ print(f"exported {n} span(s)")
82
+ time.sleep(max(1, a.interval))
83
+
84
+
85
+ if __name__ == "__main__":
86
+ raise SystemExit(main())
stdtel/state.py ADDED
@@ -0,0 +1,118 @@
1
+ """Per-session state shared between hooks (start/stop of skill invocations)."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import os
6
+ import time
7
+ from dataclasses import asdict, dataclass, field
8
+ from pathlib import Path
9
+
10
+
11
+ def state_dir() -> Path:
12
+ d = Path(os.environ.get("STDTEL_STATE_DIR", Path.home() / ".stdtel" / "sessions"))
13
+ d.mkdir(parents=True, exist_ok=True)
14
+ return d
15
+
16
+
17
+ @dataclass
18
+ class SkillWindow:
19
+ skill: str
20
+ version: str
21
+ trigger: str # caller.type from the transcript, or "unknown"
22
+ started_at: float
23
+ ended_at: float | None = None
24
+ tool_use_id: str | None = None
25
+ load_tokens: int = 0
26
+ error: bool = False
27
+ # straight from the hook payload (validated against a live session 2026-09-10)
28
+ prompt_id: str = "" # correlates with native claude_code.* telemetry
29
+ permission_mode: str = "" # measured harness mode, not the env's guess
30
+ duration_ms: int = 0 # the harness's own timing, better than our clock
31
+
32
+
33
+ @dataclass
34
+ class SessionState:
35
+ session_id: str
36
+ transcript_offset: int = 0
37
+ started_at: float = 0.0 # wall clock at SessionStart; see stop()
38
+ resource: dict = field(default_factory=dict) # std.ticket.id, std.repo, std.team, std.harness
39
+ windows: list[SkillWindow] = field(default_factory=list)
40
+ # {tool_name: [calls, failures]} — counts only, never inputs or results.
41
+ # Aggregated per session rather than one span per tool call: at ~30 calls per
42
+ # prompt, per-call spans would multiply telemetry volume for a metric that
43
+ # only needs counts.
44
+ tool_calls: dict = field(default_factory=dict)
45
+ # outcome of the last export, so the statusline can say "spans are dropping"
46
+ # from local state rather than probing the collector on every render
47
+ last_export_ok: bool | None = None
48
+
49
+ @property
50
+ def path(self) -> Path:
51
+ return state_dir() / f"{self.session_id}.json"
52
+
53
+ @classmethod
54
+ def load(cls, session_id: str) -> "SessionState":
55
+ p = state_dir() / f"{session_id}.json"
56
+ if not p.exists():
57
+ return cls(session_id=session_id)
58
+ raw = json.loads(p.read_text())
59
+ st = cls(session_id=session_id, transcript_offset=raw.get("transcript_offset", 0),
60
+ started_at=raw.get("started_at", 0.0), resource=raw.get("resource", {}),
61
+ last_export_ok=raw.get("last_export_ok"))
62
+ st.windows = [SkillWindow(**w) for w in raw.get("windows", [])]
63
+ st.tool_calls = {k: list(v) for k, v in (raw.get("tool_calls") or {}).items()}
64
+ return st
65
+
66
+ def save(self) -> None:
67
+ """Every field must be listed here.
68
+
69
+ Each hook is a separate process, so anything not written is lost between
70
+ events — silently, because the field simply reads as its default. Two
71
+ fields were added without being persisted and produced plausible-looking
72
+ zeros rather than an error.
73
+ """
74
+ self.path.write_text(json.dumps({
75
+ "transcript_offset": self.transcript_offset,
76
+ "started_at": self.started_at,
77
+ "resource": self.resource,
78
+ "windows": [asdict(w) for w in self.windows],
79
+ "tool_calls": self.tool_calls,
80
+ "last_export_ok": self.last_export_ok,
81
+ }, indent=1))
82
+
83
+ def open_window(self, skill: str, version: str, trigger: str, tool_use_id: str | None,
84
+ prompt_id: str = "", permission_mode: str = "") -> SkillWindow:
85
+ w = SkillWindow(skill=skill, version=version, trigger=trigger,
86
+ started_at=time.time(), tool_use_id=tool_use_id,
87
+ prompt_id=prompt_id, permission_mode=permission_mode)
88
+ self.windows.append(w)
89
+ return w
90
+
91
+ def close_window(self, tool_use_id: str | None, error: bool = False) -> SkillWindow | None:
92
+ for w in reversed(self.windows):
93
+ if w.ended_at is None and (tool_use_id is None or w.tool_use_id == tool_use_id):
94
+ w.ended_at = time.time()
95
+ w.error = error
96
+ return w
97
+ return None
98
+
99
+ def record_tool(self, tool_name: str, failed: bool = False) -> None:
100
+ if not tool_name:
101
+ return
102
+ entry = self.tool_calls.setdefault(tool_name, [0, 0])
103
+ entry[0] += 1
104
+ if failed:
105
+ entry[1] += 1
106
+
107
+ def tool_totals(self) -> tuple[int, int]:
108
+ calls = sum(v[0] for v in self.tool_calls.values())
109
+ failures = sum(v[1] for v in self.tool_calls.values())
110
+ return calls, failures
111
+
112
+ def open_windows(self) -> list[SkillWindow]:
113
+ return [w for w in self.windows if w.ended_at is None]
114
+
115
+ def drain_closed(self) -> list[SkillWindow]:
116
+ closed = [w for w in self.windows if w.ended_at is not None]
117
+ self.windows = [w for w in self.windows if w.ended_at is None]
118
+ return closed