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
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())
|
stdtel/policy_report.py
ADDED
|
@@ -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
|