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/statusline.py ADDED
@@ -0,0 +1,101 @@
1
+ """A statusline that surfaces data-quality faults while they are still fixable.
2
+
3
+ Wired via the `statusLine` setting. Claude Code sends session JSON on stdin,
4
+ debounces at 300ms and **cancels an in-flight script** when a new update arrives,
5
+ so this reads local state only — no catalogue parse in the common path, and never
6
+ a network call.
7
+
8
+ It shows faults you can act on now:
9
+
10
+ stdtel ⚠ no ticket · 2 unversioned
11
+ stdtel PLAT-42
12
+
13
+ An unattributed branch renamed tomorrow does not retroactively attribute today's
14
+ PRs, which is why this belongs in front of you rather than in a weekly report.
15
+
16
+ **It deliberately shows no cost or token total.** The payload offers both.
17
+ docs/for-developers.md commits to per-skill analysis rather than individual
18
+ measurement, and a live running total of your own spend in your editor reads as
19
+ surveillance however it is framed — and invites optimising the number rather than
20
+ the work.
21
+ """
22
+ from __future__ import annotations
23
+
24
+ import json
25
+ import os
26
+ import sys
27
+
28
+ PREFIX = "stdtel"
29
+
30
+
31
+ def _off() -> bool:
32
+ if os.environ.get("STDTEL_STATUSLINE", "").strip().lower() in ("off", "0", "false", "no"):
33
+ return True
34
+ from stdtel.hooks.cli import disabled
35
+ return disabled()
36
+
37
+
38
+ def _uncatalogued(state) -> int:
39
+ """Open skill windows whose name the catalogue does not know.
40
+
41
+ Only pays for the catalogue when there are windows to check, which keeps the
42
+ common render free of a PyYAML parse.
43
+ """
44
+ windows = getattr(state, "windows", None) or []
45
+ if not windows:
46
+ return 0
47
+ try:
48
+ from stdtel.hooks.cli import _catalogue, _resolve
49
+ cat = _catalogue()
50
+ return sum(1 for w in windows if _resolve(w.skill, cat)[0] is None)
51
+ except Exception: # noqa: BLE001 - never break the status bar
52
+ return 0
53
+
54
+
55
+ def render(payload: dict) -> str:
56
+ """The line to display. Empty string when there is nothing worth saying."""
57
+ if _off():
58
+ return ""
59
+ try:
60
+ session_id = (payload or {}).get("session_id")
61
+ if not session_id:
62
+ return ""
63
+ from stdtel.state import SessionState
64
+ state = SessionState.load(str(session_id))
65
+ except Exception: # noqa: BLE001
66
+ return ""
67
+
68
+ ticket = (getattr(state, "resource", None) or {}).get("std.ticket.id")
69
+ faults = []
70
+ if ticket == "unattributed":
71
+ faults.append("no ticket")
72
+ n = _uncatalogued(state)
73
+ if n:
74
+ faults.append(f"{n} unversioned")
75
+ if getattr(state, "last_export_ok", None) is False:
76
+ faults.append("spans dropping")
77
+
78
+ if faults:
79
+ return f"{PREFIX} ⚠ " + " · ".join(faults)
80
+ if ticket and ticket != "unattributed":
81
+ return f"{PREFIX} {ticket}"
82
+ return ""
83
+
84
+
85
+ def main(argv: list[str] | None = None) -> int:
86
+ """Always exits 0: a broken statusline must never break the status bar."""
87
+ try:
88
+ payload = json.load(sys.stdin)
89
+ except Exception: # noqa: BLE001
90
+ payload = {}
91
+ try:
92
+ line = render(payload)
93
+ except Exception: # noqa: BLE001
94
+ line = ""
95
+ if line:
96
+ print(line)
97
+ return 0
98
+
99
+
100
+ if __name__ == "__main__":
101
+ raise SystemExit(main())
stdtel/transcript.py ADDED
@@ -0,0 +1,174 @@
1
+ """Incremental Claude Code transcript (JSONL) reader and token attribution.
2
+
3
+ Attribution rule (design §4.2/§6): tokens from llm requests *after* a skill
4
+ loaded and before the turn ends are the skill's "tail". When several skills
5
+ load in one turn the tail is split proportionally by load order (later skills
6
+ take the remainder after their own start), and a first-skill-only figure is
7
+ kept as a sensitivity check.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ from dataclasses import dataclass, field
13
+ from pathlib import Path
14
+
15
+ TOKEN_KEYS = ("input_tokens", "output_tokens", "cache_read_input_tokens", "cache_creation_input_tokens")
16
+
17
+
18
+ @dataclass
19
+ class Usage:
20
+ input_tokens: int = 0
21
+ output_tokens: int = 0
22
+ cache_read_input_tokens: int = 0
23
+ cache_creation_input_tokens: int = 0
24
+
25
+ def add(self, other: dict | "Usage") -> "Usage":
26
+ src = other if isinstance(other, dict) else other.__dict__
27
+ for k in TOKEN_KEYS:
28
+ setattr(self, k, getattr(self, k) + int(src.get(k, 0) or 0))
29
+ return self
30
+
31
+ @property
32
+ def total(self) -> int:
33
+ return sum(getattr(self, k) for k in TOKEN_KEYS)
34
+
35
+ def as_attributes(self) -> dict:
36
+ return {f"gen_ai.usage.{k}": getattr(self, k) for k in TOKEN_KEYS}
37
+
38
+
39
+ @dataclass
40
+ class LlmRequest:
41
+ ts: float
42
+ model: str
43
+ usage: Usage
44
+
45
+
46
+ @dataclass
47
+ class SkillLoad:
48
+ ts: float
49
+ skill: str
50
+ tool_use_id: str | None
51
+ load_tokens: int = 0
52
+ caller: str = "" # tool_use "caller.type", e.g. "direct"
53
+
54
+
55
+ @dataclass
56
+ class TranscriptSlice:
57
+ requests: list[LlmRequest] = field(default_factory=list)
58
+ skill_loads: list[SkillLoad] = field(default_factory=list)
59
+ new_offset: int = 0
60
+
61
+ def totals(self) -> Usage:
62
+ """Every token in the slice, whether or not a skill was loaded.
63
+
64
+ This is the denominator for cost-per-PR. Skill tail attribution only sees
65
+ requests after a skill loads, so a session that never loads one is
66
+ invisible to it — which is most sessions.
67
+ """
68
+ total = Usage()
69
+ for r in self.requests:
70
+ total.add(r.usage)
71
+ return total
72
+
73
+ def models(self) -> list[str]:
74
+ seen = []
75
+ for r in self.requests:
76
+ if r.model not in seen:
77
+ seen.append(r.model)
78
+ return seen
79
+
80
+
81
+ def _ts(entry: dict) -> float:
82
+ t = entry.get("timestamp")
83
+ if isinstance(t, (int, float)):
84
+ return float(t)
85
+ if isinstance(t, str):
86
+ from datetime import datetime
87
+ try:
88
+ return datetime.fromisoformat(t.replace("Z", "+00:00")).timestamp()
89
+ except ValueError:
90
+ pass
91
+ return 0.0
92
+
93
+
94
+ def read_slice(path: Path, offset: int = 0) -> TranscriptSlice:
95
+ """Read new JSONL lines from byte offset. Tolerates partial trailing line."""
96
+ out = TranscriptSlice(new_offset=offset)
97
+ # Path("") is ".", which *exists* as a directory: exists() let it through and
98
+ # open() then raised IsADirectoryError, which hooks swallow into a silent
99
+ # no-telemetry state. Require an actual file.
100
+ if not path.is_file():
101
+ return out
102
+ with path.open("rb") as f:
103
+ f.seek(offset)
104
+ data = f.read()
105
+ last_nl = data.rfind(b"\n")
106
+ if last_nl == -1:
107
+ return out
108
+ chunk, out.new_offset = data[: last_nl + 1], offset + last_nl + 1
109
+ for raw in chunk.splitlines():
110
+ if not raw.strip():
111
+ continue
112
+ try:
113
+ e = json.loads(raw)
114
+ except json.JSONDecodeError:
115
+ continue
116
+ msg = e.get("message") or {}
117
+ if e.get("type") == "assistant" and isinstance(msg, dict):
118
+ usage = msg.get("usage")
119
+ if usage:
120
+ out.requests.append(LlmRequest(ts=_ts(e), model=msg.get("model", "unknown"),
121
+ usage=Usage().add(usage)))
122
+ for block in msg.get("content") or []:
123
+ if isinstance(block, dict) and block.get("type") == "tool_use" and block.get("name") == "Skill":
124
+ inp = block.get("input") or {}
125
+ caller = block.get("caller")
126
+ out.skill_loads.append(SkillLoad(
127
+ ts=_ts(e),
128
+ skill=str(inp.get("skill") or inp.get("name") or ""),
129
+ tool_use_id=block.get("id"),
130
+ caller=str((caller or {}).get("type") or "")))
131
+ elif e.get("type") == "user" and isinstance(msg, dict):
132
+ # tool_result for a Skill call: size of returned content approximates load tokens
133
+ for block in msg.get("content") or []:
134
+ if isinstance(block, dict) and block.get("type") == "tool_result":
135
+ for sl in out.skill_loads:
136
+ if sl.tool_use_id and sl.tool_use_id == block.get("tool_use_id"):
137
+ content = block.get("content")
138
+ text = content if isinstance(content, str) else json.dumps(content or "")
139
+ sl.load_tokens = max(1, len(text) // 4) # chars/4 heuristic
140
+ return out
141
+
142
+
143
+ @dataclass
144
+ class Attribution:
145
+ skill: str
146
+ tail: Usage
147
+ tail_first_only: Usage # sensitivity check: all tokens after the *first* skill in the turn
148
+ models: list[str]
149
+ request_count: int
150
+
151
+
152
+ def attribute(sl: TranscriptSlice) -> list[Attribution]:
153
+ """Assign post-load llm requests to skills in load order."""
154
+ loads = sorted(sl.skill_loads, key=lambda s: s.ts)
155
+ reqs = sorted(sl.requests, key=lambda r: r.ts)
156
+ results: list[Attribution] = []
157
+ for i, load in enumerate(loads):
158
+ nxt = loads[i + 1].ts if i + 1 < len(loads) else float("inf")
159
+ tail = Usage()
160
+ models: list[str] = []
161
+ n = 0
162
+ for r in reqs:
163
+ if load.ts <= r.ts < nxt:
164
+ tail.add(r.usage); n += 1
165
+ if r.model not in models:
166
+ models.append(r.model)
167
+ first_only = Usage()
168
+ if i == 0:
169
+ for r in reqs:
170
+ if r.ts >= load.ts:
171
+ first_only.add(r.usage)
172
+ results.append(Attribution(skill=load.skill, tail=tail, tail_first_only=first_only,
173
+ models=models, request_count=n))
174
+ return results
@@ -0,0 +1,246 @@
1
+ Metadata-Version: 2.4
2
+ Name: stdtel
3
+ Version: 0.2.3
4
+ Summary: Telemetry and metadata capture for standards-as-skills (Claude Code + Copilot)
5
+ License-Expression: MIT
6
+ Project-URL: Homepage, https://github.com/amiable-dev/skills-telemetry
7
+ Project-URL: Repository, https://github.com/amiable-dev/skills-telemetry
8
+ Project-URL: Issues, https://github.com/amiable-dev/skills-telemetry/issues
9
+ Keywords: telemetry,opentelemetry,claude-code,copilot,skills,governance
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Topic :: Software Development :: Quality Assurance
14
+ Classifier: Topic :: System :: Monitoring
15
+ Requires-Python: >=3.10
16
+ Description-Content-Type: text/markdown
17
+ Requires-Dist: pyyaml>=6
18
+ Requires-Dist: opentelemetry-sdk>=1.25
19
+ Requires-Dist: opentelemetry-exporter-otlp-proto-http>=1.25
20
+ Provides-Extra: dev
21
+ Requires-Dist: pytest>=8; extra == "dev"
22
+ Provides-Extra: warehouse
23
+ Requires-Dist: psycopg[binary]>=3.1; extra == "warehouse"
24
+ Requires-Dist: requests>=2.31; extra == "warehouse"
25
+
26
+ # standards-telemetry
27
+
28
+ Telemetry and metadata capture for **standards-as-skills** — attributes token cost and outcomes to
29
+ individual skills across Claude Code and GitHub Copilot, joined to policy (OPA/Rego), delivery
30
+ (Linear/GitHub) and quality data. Design rationale: [docs/design-proposal.md](docs/design-proposal.md).
31
+
32
+ Metadata only. No prompt, response or file content is ever emitted; the collector drops it again as
33
+ defence in depth. If it runs on your machine, [docs/for-developers.md](docs/for-developers.md) lists
34
+ every field that leaves it — and `export STDTEL_DISABLED=1` turns it off entirely.
35
+
36
+ ## When can I trust these numbers?
37
+
38
+ Telemetry starts producing plausible-looking ratios on day one. Most of them mean nothing yet. The
39
+ phases below are set by **data volume, not elapsed time** — how long each takes depends entirely on
40
+ team size, and a small team may never leave the first one.
41
+
42
+ | phase | you have | what it supports | what it does not |
43
+ |---|---|---|---|
44
+ | **Descriptive** | anything below the floor | cost, usage, and finding data-quality faults — `unversioned` skills, `unattributed` branches | any comparison between skills, harnesses, or arms |
45
+ | **Directional** | 30+ merged PRs per arm, 5+ developers | spotting large effects (>40%) as a hypothesis, always with an interval | point estimates, or a keep/deprecate decision |
46
+ | **Inferential** | 150-400+ PRs per arm, depending on effect size | keep / refine / merge / deprecate decisions | detecting effects under 20%, which needs 900+ |
47
+
48
+ **The hard floor: below 30 merged PRs per arm, or fewer than 5 developers, report descriptively and make no comparative claim.**
49
+
50
+ Early on, the most valuable thing this data does is find its own faults. A high share of `unversioned`
51
+ skills or `unattributed` tickets bounds every later conclusion, and both are fixable now — see
52
+ [`stdtel-onboard`](skills/stdtel-onboard/SKILL.md) and ticket-prefixed branches.
53
+
54
+ The failure mode this exists to prevent: reading a scorecard after two weeks, seeing a skill
55
+ "underperform" across nine PRs, and deprecating it. Nine PRs cannot distinguish a bad skill from a
56
+ quiet fortnight. Full derivations and the assumptions behind every figure:
57
+ [docs/evaluation-power.md](docs/evaluation-power.md). Worked examples of asking these
58
+ questions, including the ones the data cannot answer:
59
+ [docs/insight-walkthroughs.md](docs/insight-walkthroughs.md).
60
+
61
+ ## Documentation
62
+
63
+ | page | for |
64
+ |---|---|
65
+ | [docs/for-developers.md](docs/for-developers.md) | **if this runs on your machine**: exactly what is collected, and how to switch it off |
66
+ | [docs/reference.md](docs/reference.md) | every CLI, its flags and exit codes, and the authoritative `STDTEL_*` table |
67
+ | [docs/skills.md](docs/skills.md) | each skill and the agent — when to use, when not to, what it refuses |
68
+ | [docs/evaluation-power.md](docs/evaluation-power.md) | how much data before a comparison means anything |
69
+ | [docs/insight-walkthroughs.md](docs/insight-walkthroughs.md) | worked examples with real output, including the misreadings |
70
+ | [docs/local-stack.md](docs/local-stack.md) | endpoints, credentials, the verification ladder, and how to reset |
71
+ | [docs/adrs/](docs/adrs/) | why things are the way they are |
72
+
73
+ ## Layout
74
+
75
+ ```
76
+ skills/<name>/SKILL.md skill catalogue with validated front-matter (the contract)
77
+ stdtel/manifest.py front-matter parser + `stdtel-validate` CI gate
78
+ stdtel/hooks/cli.py Claude Code hooks: session-start | pre-tool-use | post-tool-use | stop
79
+ stdtel/transcript.py incremental JSONL reader + token attribution (tail rule, first-only sensitivity)
80
+ stdtel/exporter.py std.skill.invocation spans via OTLP/HTTP (content scrubbed)
81
+ stdtel/enrich.py join keys: std.ticket.id from branch, std.repo, std.team, std.harness
82
+ stdtel/skillmap.py generates collector/copilot-skill-map.yaml for Copilot tool-call mapping
83
+ collector/otel-collector.yaml drop content → normalise gen_ai.* → map Copilot skills → pseudonymise → spanmetrics
84
+ deploy/docker-compose.yml collector + Tempo + Prometheus + Grafana + Postgres; `langfuse` profile optional
85
+ deploy/smoke.sh eight-hop verification ladder (`mise run smoke`)
86
+ collector/overlay-*.yaml merged over the base config; `none` is the default no-op, `langfuse` adds an exporter
87
+ warehouse/schema.sql skill_invocation, session_cost, ticket, pull_request, policy_result, defect, skill_eval
88
+ warehouse/scorecard.sql weekly per-skill scorecard → keep / refine / review-merge / deprecate
89
+ warehouse/load_traces.py Tempo → Postgres loader
90
+ eval/run_eval.py offline with/without-skill eval, real OPA grading (`--dry-run` is a smoke test)
91
+ eval/power.py generates every table in docs/evaluation-power.md; CI checks it is current
92
+ policies/ Rego behind the primary metric: logging.*, telemetry.manifest_valid
93
+ agents/ skill-scorecard-analyst: keep / refine / merge / deprecate from the data
94
+ warehouse/load_delivery.py GitHub → ticket / pull_request / defect; policy_result from a CI artefact
95
+ examples/settings.*.json hook + OTel wiring: `global` installs once, `project` overrides per repo
96
+ docs/adrs/ six ADRs: distribution, delivery data, hook constraints, identity, integrity, Langfuse
97
+ plugin.json Agent Plugins v1 manifest (portable `skills/` is the shared half)
98
+ .claude-plugin/ Claude Code plugin + marketplace manifest
99
+ hooks/, com.github.copilot/ per-harness hook manifests, generated from stdtel/install.py::EVENTS
100
+ stdtel/install.py `stdtel-install`: absolute-path resolution + settings merge
101
+ mise.toml toolchain (Python 3.13) + `.venv` + tasks wrapping the Makefile
102
+ ```
103
+
104
+ ## Quick start
105
+
106
+ Two separate jobs: **install the harness wiring once for your user**, then **onboard each project** you
107
+ want attributed telemetry from. Skipping the second step still gives you spans — the skills just come
108
+ through as `unversioned`, with `std.team=unknown`.
109
+
110
+ ### 1. Install once, globally
111
+
112
+ ```bash
113
+ uv tool install stdtel # or: pipx install stdtel
114
+ stdtel-install settings # merges hooks into ~/.claude/settings.json
115
+ ```
116
+
117
+ > **Not published yet.** `stdtel` is not on PyPI until the first release
118
+ > ([docs/releasing.md](docs/releasing.md)). Until then install from a checkout:
119
+ > `uv tool install /path/to/skills-telemetry`. This matters more than it looks: the plugin's hooks
120
+ > call a launcher that exits silently when it cannot find the CLI, so an uninstalled package produces
121
+ > **no data and no error**. `stdtel-doctor` says so explicitly.
122
+
123
+ `stdtel-install` resolves the **absolute path** of the `stdtel-hook` it was installed alongside and
124
+ writes that into the config. This is not cosmetic: hook processes get a non-login `sh -c` and inherit
125
+ whatever PATH launched the harness, so a bare `stdtel-hook` is unresolvable whenever a version manager
126
+ (mise, asdf, pyenv) or an activated venv is what put it there. The same reason pre-commit bakes
127
+ `sys.executable` into the git hook it generates. `stdtel-install where` prints the path it will use;
128
+ `--dry-run` shows the JSON without writing.
129
+
130
+ Claude Code also **strips `OTEL_*` from every subprocess it spawns**, so point the exporter at your
131
+ collector with the `STDTEL_`-namespaced variables, which survive:
132
+
133
+ ```bash
134
+ export STDTEL_OTLP_ENDPOINT=http://collector.internal:4318 # default: http://localhost:4318
135
+ export STDTEL_OTLP_TIMEOUT=2 # seconds; bounds a dead-collector stall
136
+ ```
137
+
138
+ With no `STDTEL_SKILLS_ROOT` set, the catalogue is read from `~/.claude/skills`. Symlink your
139
+ standards there and every project gets versioned spans:
140
+
141
+ ```bash
142
+ ln -s "$PWD/skills/structured-logging" ~/.claude/skills/structured-logging
143
+ stdtel-validate ~/.claude/skills # same contract gate CI runs
144
+ ```
145
+
146
+ #### As a plugin
147
+
148
+ The repo is laid out for three loaders at once (ADR-001), so it installs as a plugin without a
149
+ separate packaging step:
150
+
151
+ ```bash
152
+ /plugin marketplace add amiable-dev/skills-telemetry
153
+ /plugin install stdtel@amiable-standards
154
+ ```
155
+
156
+ The shipped `hooks/hooks.json` carries the bare command name, because a distributed manifest cannot
157
+ know your install path — run `stdtel-install settings` afterwards to bind it to an absolute one.
158
+
159
+ #### Copilot
160
+
161
+ Copilot needs **no code from us**. It emits first-party OpenTelemetry with per-tool-call spans and
162
+ token counts; point it at the same collector (user settings, `COPILOT_OTEL_*` env vars, or the
163
+ enterprise `managed-settings.json` `telemetry` block for a fleet).
164
+
165
+ If you do run our hooks on Copilot as well, install them from `com.github.copilot/hooks/hooks.json`,
166
+ which sets `STDTEL_HARNESS` per hook. That is load-bearing: VS Code Copilot **reads
167
+ `~/.claude/settings.json`**, and its snake_case payload dialect is indistinguishable from Claude
168
+ Code's — without that env block, Copilot activity is recorded as `claude-code`.
169
+
170
+ ### 2. Onboard a project
171
+
172
+ Per-project overrides go in `<project>/.claude/settings.json` — Claude Code merges them over the user
173
+ file, so repeat only what differs. Do **not** repeat the `hooks` block: it is already registered globally
174
+ and a second copy fires each hook twice.
175
+
176
+ ```bash
177
+ cd ~/projects/payments-api
178
+ mkdir -p .claude && cp ~/projects/skills-telemetry/examples/settings.project.json .claude/settings.json
179
+ $EDITOR .claude/settings.json # STDTEL_TEAM is the one you must set
180
+ git checkout -b feature/PLAT-123-add-audit-log # ticket prefix -> std.ticket.id join key
181
+ ```
182
+
183
+ | variable | where | meaning |
184
+ |---|---|---|
185
+ | `STDTEL_TEAM` | project | owning team on every span; `unknown` until you set it |
186
+ | `STDTEL_HARNESS_MODE` | project | `agent` / `interactive` — keeps the Claude Code vs Copilot split fair |
187
+ | `STDTEL_SKILLS_ROOT` | project | extra catalogue root(s), `os.pathsep`-separated. A relative path resolves against the project directory; `~/.claude/skills` is always searched last, and the earliest root wins a name collision |
188
+ | `STDTEL_HARNESS`, `OTEL_*` | global | harness label and collector endpoint |
189
+
190
+ Then verify the loop end to end:
191
+
192
+ ```bash
193
+ claude # invoke a skill in the project
194
+ cat ~/.stdtel/sessions/*.json # a window with skill, version, tool_use_id
195
+ curl -s 'http://localhost:3200/api/search?tags=name%3Dstd.skill.invocation' | jq '.traces[0]'
196
+ ```
197
+
198
+ A `std.skill.version` of `unversioned` means the name in the transcript matched no `SKILL.md` in any root
199
+ — check `STDTEL_SKILLS_ROOT` and that the skill's front-matter `name` matches what you invoked.
200
+
201
+ Copilot: enable managed OTel export (VS Code / CLI) pointing at the same collector with resource attributes
202
+ `std.harness=copilot-vscode`, `std.team=<team>`; `collector/otel-collector.yaml` maps catalogued skill
203
+ tool-calls onto `std.skill.*`.
204
+
205
+ ## Span schema
206
+
207
+ Two span types, emitted at `Stop`. **They overlap by design and must never be summed:**
208
+ `std.session.cost` is the session's total spend, `std.skill.invocation` attributes a *share* of that
209
+ total to one skill. Use session cost as the denominator for cost-per-PR; use invocation tail tokens to
210
+ compare skills with each other.
211
+
212
+ ### `std.session.cost`
213
+
214
+ Emitted once per turn that made any LLM request, **whether or not a skill was loaded** — a session that
215
+ never loads a skill is still spend, and excluding it would silently understate cost per PR.
216
+
217
+ | attribute | source |
218
+ |---|---|
219
+ | `gen_ai.usage.{input,output,cache_read_input,cache_creation_input}_tokens` | whole transcript slice |
220
+ | `std.session.llm_requests`, `gen_ai.request.model` | transcript |
221
+ | `std.session.tool_calls`, `std.session.tool_failures` | every tool call in the turn |
222
+ | `std.session.tool.<name>.{calls,failures}` | per tool; `failures` omitted when zero |
223
+ | `std.ticket.id`, `std.repo`, `std.team`, `std.harness` | resource (SessionStart) |
224
+
225
+ ### `std.skill.invocation`
226
+
227
+ | attribute | source |
228
+ |---|---|
229
+ | `std.skill.name/version/trigger`, `std.standard_id`, `std.policy.ids` | hook + manifest |
230
+ | `std.skill.invoked_as`, `std.skill.plugin` | raw invocation string (plugin skills are namespaced) |
231
+ | `std.skill.load_tokens`, `std.skill.tail_tokens`, `std.skill.tail_tokens_first_only`, `std.skill.llm_requests` | transcript attribution |
232
+ | `gen_ai.usage.{input,output,cache_read_input,cache_creation_input}_tokens`, `gen_ai.request.model` | transcript |
233
+ | `std.ticket.id`, `std.repo`, `std.team`, `std.harness`, `std.harness.mode` | resource (SessionStart) |
234
+ | `std.user.hash` | collector (pseudonymised) |
235
+
236
+ ## Known limitations
237
+
238
+ - Tool counts are counts only. `PostToolUse` fires for **every** tool, so `tool_input` and
239
+ `tool_response` carry commands, file contents and diffs — `scrub()` refuses those attribute names
240
+ outright and a test asserts nothing leaks.
241
+ - Skill name is parsed from the Skill tool input in hooks (the field is `skill`, verified against 120 real invocations). Parsing is isolated in `hooks/cli.py::_skill_from_payload`.
242
+ - `std.skill.trigger` reports the transcript's `caller.type` where present, else `unknown` — it is never guessed.
243
+ - Tail attribution splits by load order; when several skills load in one turn compare against `tail_tokens_first_only`.
244
+ - Copilot granularity is per turn; use Claude Code's finer data for within-harness tuning only.
245
+ - `load_tokens` uses a chars/4 heuristic on the Skill tool result.
246
+ - `gen_ai.*` conventions are still *Development* upstream; extend `transform/normalise` as names move.
@@ -0,0 +1,24 @@
1
+ eval/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ eval/power.py,sha256=TrxW0_we9iGocQW6BboDbRuw4Tso5M8TV4eRB_lZYXM,8124
3
+ eval/run_eval.py,sha256=5PpAdNE7C1DuyzVEwpR8p0KH8vOP8Kx_ZqzBv2S95Fg,7035
4
+ eval/fixtures/fastapi-min/app/main.py,sha256=OrUN28lophHXcdkThQyZEqUayoBVVAByKtKx-ncJ1VA,295
5
+ stdtel/__init__.py,sha256=P2lKoLuBR4I0Fq0zto7C5ZxCSHhKbILOSVesJsyC9jo,70
6
+ stdtel/doctor.py,sha256=A9jXXieZ8OVZxTgihuX1VkOTKDln9jNKHYsbDgYiaI4,9294
7
+ stdtel/enrich.py,sha256=ZDNS2p0LXEd8CAl2-vg98QWZvqIhKVtIikGdmA-qahs,2489
8
+ stdtel/exporter.py,sha256=agux9opqr0xqa9yl3AXxVwrIoDpvO_YUq4uw89XwPNU,4833
9
+ stdtel/install.py,sha256=FMhJ9D48aiUO9VPezFkGHpI5u-x-gYYZRdQIocDsS58,7093
10
+ stdtel/manifest.py,sha256=YpTg6jGiiCbB2F24O-yphhrcPAKS8RgiCgtvuwc-01Q,7987
11
+ stdtel/policy_report.py,sha256=e_niGxaTMIG4_cr3U8Z72o-QehLj3D0EpxCGoDdxI6Y,5362
12
+ stdtel/skillmap.py,sha256=beOqxsWK97Y7RCLTAxqVKq7-fvcOMKYX3r5jqsp4zHE,568
13
+ stdtel/spool.py,sha256=T35kDvUrhoQ659gbKGh8rfxI0UqzhBk6UW-IwOft2tY,3932
14
+ stdtel/spool_export.py,sha256=4dp15E5I3Qsvj_EF4ZAPHliLQ9cZhqhOp1zN1wzk8MI,3195
15
+ stdtel/state.py,sha256=beYLfy7nHX1sMHsucYWtTHATGtQ_S6JdkQxqgCgKBsY,4860
16
+ stdtel/statusline.py,sha256=fHxwXSNo8xR5nHKk4tJj3vI2o5xk5sc8TKxNECBsado,3251
17
+ stdtel/transcript.py,sha256=mkxXqHfb9VCMvXlVRiTfC5EUulze9uBnfMmqTECrPS0,6254
18
+ stdtel/hooks/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
19
+ stdtel/hooks/cli.py,sha256=EKC0goOpliSgneapMRTzA4a7KwEcX2Xtejcx5zQ_Z7Q,13233
20
+ stdtel-0.2.3.dist-info/METADATA,sha256=N5DY1fhj8cYk5Nz1FX_3Y8j3Df96U0A5Z8wCquPICRg,14568
21
+ stdtel-0.2.3.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
22
+ stdtel-0.2.3.dist-info/entry_points.txt,sha256=tk3APT1LKC73AkH7jR9gME0LLnv9r2YiYB6akEA9rac,330
23
+ stdtel-0.2.3.dist-info/top_level.txt,sha256=prBM2zwjJr9B3uMWyJJNLPWLqTDEwOxYkwcie3i637w,12
24
+ stdtel-0.2.3.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,9 @@
1
+ [console_scripts]
2
+ stdtel-doctor = stdtel.doctor:main
3
+ stdtel-eval = eval.run_eval:main
4
+ stdtel-export = stdtel.spool_export:main
5
+ stdtel-hook = stdtel.hooks.cli:main
6
+ stdtel-install = stdtel.install:main
7
+ stdtel-policy-report = stdtel.policy_report:main
8
+ stdtel-statusline = stdtel.statusline:main
9
+ stdtel-validate = stdtel.manifest:cli
@@ -0,0 +1,2 @@
1
+ eval
2
+ stdtel