Chemenu 2.1.0 - deterministischer Wissenskompiler
CI / verify (push) Failing after 32s
Release / release (push) Successful in 38s

Chemenu kompiliert Rohnotizen zu einem verlinkten, quellengebundenen Wiki:
raw/ -> types/ + tools/ -> kb/ -> reports/. Was mechanisch ist, macht
tools/wikitool; was Urteil braucht, macht ein Agent unter Contracts, deren
Grenzen in Code durchgesetzt sind statt im Prompt.

Dieser Commit ist der Startpunkt der oeffentlichen Historie. Die vorherige
Entwicklung fand in einer privaten Instanz statt und ist nicht Teil dieses
Repositorys; ihre Erzaehlung steht vollstaendig in CHANGES.md, das mit 44
Eintraegen von 0.1.0 bis 2.1.0 erhalten geblieben ist.

Der mitgelieferte Korpus ist ein Testbett und eine Demo: 170 Seiten ueber den
Stack selbst - Gates, Lint, Versionierung, Suche, das Wiki-Muster. Er
dokumentiert das Werkzeug mit den eigenen Mitteln des Werkzeugs.

Lizenz: AGPL-3.0 fuer den Stack (tools/, types/), CC-BY-4.0 fuer die Inhalte.
Die Grenze zwischen beiden ist der Dateiplan, den dist export berechnet -
siehe NOTICE.
This commit is contained in:
2026-09-01 16:24:34 +02:00
commit 18ae28f918
368 changed files with 50628 additions and 0 deletions
+31
View File
@@ -0,0 +1,31 @@
"""Trace telemetry: the harness-independent record of what a session did.
`schema` is the event contract, `scrub` the redaction applied to every event,
`writer` the append path. Stdlib only, deliberately: a hook handler imports this
package on every tool call and must not need the venv or a schema library.
The module is `writer` rather than `emit` so that the exported `emit()` function
does not shadow it - `from chemenu.telemetry import emit` should hand a caller
the function it is going to call.
"""
from chemenu.telemetry.schema import (
CORE_EVENTS,
EVENTS,
HARNESS_CAPABILITIES,
SCHEMA_VERSION,
SOURCES,
)
from chemenu.telemetry.writer import emit, enabled, trace_path, trace_root, write_event
__all__ = [
"emit",
"enabled",
"trace_path",
"trace_root",
"write_event",
"CORE_EVENTS",
"EVENTS",
"HARNESS_CAPABILITIES",
"SCHEMA_VERSION",
"SOURCES",
]
+69
View File
@@ -0,0 +1,69 @@
"""Reading a trace back.
The format's owner owns the reader: a consumer that re-derived the sort order or
the session-directory rule would drift from the writer the first time either
changed.
"""
from __future__ import annotations
import json
from pathlib import Path
from chemenu.telemetry.writer import trace_path
def sort_key(record: dict) -> tuple:
"""`seq` counts within one process only - a trace is written by the CLI in
one process and by a hook handler in another, so it orders by time first."""
return (record.get("ts", ""), record.get("pid", 0), record.get("seq", 0))
def read_trace(session: str | None = None, path: Path | None = None) -> list[dict]:
"""Return one session's events in order. A missing trace is an empty one:
a session that never recorded anything is a normal state, not an error."""
target = path or trace_path(session)
if not target.exists():
return []
records = []
for line in target.read_text(encoding="utf-8").splitlines():
line = line.strip()
if not line:
continue
try:
record = json.loads(line)
except json.JSONDecodeError:
# A torn last line can only happen if a writer died mid-append.
# Losing it is better than refusing to read the rest.
continue
if isinstance(record, dict):
records.append(record)
records.sort(key=sort_key)
return records
def sessions(root: Path | None = None) -> list[str]:
"""Every session that has a trace, most recently modified first."""
from chemenu.telemetry.writer import trace_root
base = root or trace_root()
if not base.exists():
return []
traces = [p for p in base.glob("*/trace.jsonl") if p.is_file()]
traces.sort(key=lambda p: p.stat().st_mtime, reverse=True)
return [p.parent.name for p in traces]
def completeness(records: list[dict]) -> list[str]:
"""What the harnesses behind this trace said they could report.
The union across sources, because a session traced by both `wikitool` and a
hook adapter can report what either of them can.
"""
seen: list[str] = []
for record in records:
if record.get("event") != "session.start":
continue
for item in record.get("attrs", {}).get("completeness", []):
if item not in seen:
seen.append(item)
return seen
+173
View File
@@ -0,0 +1,173 @@
"""The trace event contract: one JSON object per line, one line per event.
Why the schema lives in code rather than under `types/`: `types/` is the page
type system - every spec there describes a `kb/` page, carries a `base_dir`, and
is discovered by `wikitool types list`. A trace event is not a page, so putting
it there would put a non-page into the page catalog.
Ordering. Events are appended by several processes at once (the CLI in one
process, a hook handler in another, one per tool call), so `seq` is a
*per-process* counter and cannot be compared across processes. Sort a trace by
`(ts, pid, seq)`.
Degradation. No consumer may require an event class that some harness cannot
produce - Mistral Vibe has three hooks where Claude Code has thirty. `session.start`
carries a `completeness` list naming the classes its harness can emit, so a scorer
can say "not measurable here" instead of silently scoring zero. The common core
every surface provides is `tool.pre`, `tool.post`, `wikitool.call`, `gate.refused`.
"""
from __future__ import annotations
import os
from datetime import datetime, timezone
SCHEMA_VERSION = 1
# Where an event came from. `runner` is the eval runner itself, which owns the
# session boundaries because not every harness reports them.
SOURCES = frozenset(
{
"wikitool",
"runner",
"claude-code",
"copilot-cli",
"vscode-chat",
"mistral-vibe",
}
)
# The common core, available on every surface. Scorers may depend on these.
CORE_EVENTS = frozenset({"tool.pre", "tool.post", "wikitool.call", "gate.refused"})
# Everything else refines a scorer but may never be a precondition for one.
OPTIONAL_EVENTS = frozenset(
{
"session.start",
"session.end",
"session.error",
"prompt.submitted",
"assistant.message",
"turn.end",
"tool.error",
"instructions.loaded",
"subagent.start",
"subagent.stop",
"compaction",
"page.written",
"publish.commit",
"budget.state",
"gate.cleared",
}
)
EVENTS = CORE_EVENTS | OPTIONAL_EVENTS
# Which event classes each harness can actually produce, from its documented
# hook surface. Written into `session.start` as `completeness` so a trace is
# self-describing: a missing class means "this harness cannot report it", not
# "the agent never did it".
HARNESS_CAPABILITIES: dict[str, tuple[str, ...]] = {
"claude-code": (
"session.start",
"session.end",
"prompt.submitted",
"tool.pre",
"tool.post",
"tool.error",
"turn.end",
"instructions.loaded",
"subagent.start",
"subagent.stop",
"compaction",
),
"copilot-cli": (
"session.start",
"session.end",
"session.error",
"prompt.submitted",
"tool.pre",
"tool.post",
"tool.error",
"turn.end",
"subagent.start",
"subagent.stop",
"compaction",
),
# Three hooks only: pre_tool, post_tool, post_agent. No session, prompt,
# compaction, permission or subagent lifecycle event exists to hook.
"mistral-vibe": ("tool.pre", "tool.post", "turn.end"),
# Post-hoc import from the chronicle store: turns and touched files, no
# tool-level lifecycle.
"vscode-chat": ("session.start", "prompt.submitted", "assistant.message", "tool.post"),
"wikitool": (
"wikitool.call",
"gate.refused",
"page.written",
"publish.commit",
"budget.state",
"gate.cleared",
),
"runner": ("session.start", "session.end"),
}
def utc_now_iso() -> str:
"""Timestamp with microseconds, so events inside one millisecond still order."""
return datetime.now(timezone.utc).isoformat(timespec="microseconds")
def make_event(
source: str,
event: str,
attrs: dict | None = None,
*,
session_id: str,
seq: int,
run_key: str | None = None,
ts: str | None = None,
trace_id: str | None = None,
span_id: str | None = None,
) -> dict:
record = {
"v": SCHEMA_VERSION,
"ts": ts or utc_now_iso(),
"session_id": session_id,
"pid": os.getpid(),
"seq": seq,
"source": source,
"event": event,
"attrs": attrs or {},
}
if run_key:
record["run_key"] = run_key
if trace_id:
record["trace_id"] = trace_id
if span_id:
record["span_id"] = span_id
return record
def validation_errors(record: dict) -> list[str]:
"""Return a list of contract violations; empty means valid.
Deliberately hand-written rather than jsonschema: this runs on the hot path
of every hook invocation, and the telemetry package must stay stdlib-only so
a hook can run it without the venv.
"""
errors: list[str] = []
for field in ("v", "ts", "session_id", "pid", "seq", "source", "event", "attrs"):
if field not in record:
errors.append(f"missing required field '{field}'")
if record.get("v") != SCHEMA_VERSION:
errors.append(f"unknown schema version {record.get('v')!r}")
if record.get("source") not in SOURCES:
errors.append(f"unknown source {record.get('source')!r}")
if record.get("event") not in EVENTS:
errors.append(f"unknown event {record.get('event')!r}")
if not isinstance(record.get("attrs", {}), dict):
errors.append("'attrs' must be an object")
if not isinstance(record.get("seq"), int):
errors.append("'seq' must be an integer")
if not isinstance(record.get("session_id"), str) or not record.get("session_id"):
errors.append("'session_id' must be a non-empty string")
return errors
+169
View File
@@ -0,0 +1,169 @@
"""Redaction applied to every trace event, in one place: the emitter.
The repo records prompts and assistant replies in cleartext, because a failure
taxonomy cannot be read out of hashes - that is the whole point of the
comprehension phase. Cleartext is only defensible with three guards, all of them
here rather than at each call site:
1. **Secret scrubbing.** Pattern-based, best effort, never a substitute for
discipline - `reports/` is gitignored and a secret scan runs in verification.
2. **A content cap**, so one 5 MB tool result cannot dominate a trace.
3. **A kill switch.** `WIKI_TRACE_CONTENT=0` drops model-facing text and keeps
only its length and SHA-256.
`raw/` file *contents* never reach a trace at all, whatever these settings say:
that text is data, not instruction (AGENTS.md invariant 4), and a trace is read
back later. Callers record a path plus a digest instead.
"""
from __future__ import annotations
import hashlib
import os
import re
# Sized like Claude Code's own OTel content limit (60 KiB), which is in turn
# sized for backends that cap an attribute at 64 KiB.
DEFAULT_MAX_CONTENT = 61440
# Attribute names holding model-facing text. Only these obey the kill switch;
# paths, tool names and exit codes stay readable either way.
CONTENT_KEYS = frozenset(
{"prompt", "response", "message", "text", "tool_input", "tool_output", "error"}
)
def _mask(name: str) -> str:
return f"[REDACTED:{name}]"
def _keep_key(name: str):
"""Replace the value of a `key: value` pair, keep the key readable."""
def repl(match: re.Match) -> str:
return f"{match.group(1)}{match.group(2)}{_mask(name)}"
return repl
# Order matters: the most specific pattern must match before a generic one can
# swallow part of it.
SECRET_PATTERNS: list[tuple[str, re.Pattern, object]] = [
(
"private-key",
re.compile(r"-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----"),
None,
),
("github-token", re.compile(r"\bgh[pousr]_[A-Za-z0-9]{16,}\b"), None),
("github-pat", re.compile(r"\bgithub_pat_[A-Za-z0-9_]{20,}\b"), None),
("aws-access-key", re.compile(r"\b(?:AKIA|ASIA)[0-9A-Z]{16}\b"), None),
("slack-token", re.compile(r"\bxox[baprs]-[A-Za-z0-9-]{10,}\b"), None),
("anthropic-key", re.compile(r"\bsk-ant-[A-Za-z0-9_-]{20,}\b"), None),
("openai-key", re.compile(r"\bsk-[A-Za-z0-9]{20,}\b"), None),
("google-api-key", re.compile(r"\bAIza[0-9A-Za-z_-]{35}\b"), None),
("onepassword-token", re.compile(r"\bops_[A-Za-z0-9+/=_-]{40,}\b"), None),
(
"jwt",
re.compile(r"\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b"),
None,
),
(
# Stops at a quote, comma or whitespace after the credential: a tool
# input is usually one line of JSON, and eating to end-of-line would
# take the rest of the payload with the secret.
"auth-header",
re.compile(
r"""(?ix)
\b(authorization|x-api-key|proxy-authorization)
(\s*[:=]\s*)
(?:bearer|basic|token)?\s*
[^\s"',;\\]+
"""
),
"keep-key",
),
(
"secret-assignment",
re.compile(
r"""(?ix)
\b([A-Za-z0-9_.-]*
(?:api[_-]?key|secret|token|password|passwd|credential)
[A-Za-z0-9_.-]*)
(\s*[:=]\s*)
(["']?[^\s"',;]{8,}["']?)
"""
),
"keep-key",
),
]
def max_content() -> int:
raw = os.environ.get("WIKI_TRACE_MAX_CONTENT")
if raw and raw.isdigit() and int(raw) > 0:
return int(raw)
return DEFAULT_MAX_CONTENT
def content_enabled() -> bool:
"""Cleartext prompts/responses on by default; `WIKI_TRACE_CONTENT=0` opts out."""
return os.environ.get("WIKI_TRACE_CONTENT", "1") != "0"
def sha256_text(text: str) -> str:
return hashlib.sha256(text.encode("utf-8", "replace")).hexdigest()
def scrub_text(text: str) -> tuple[str, list[str]]:
"""Replace known secret shapes. Returns the text and the pattern names hit."""
hits: list[str] = []
for name, pattern, mode in SECRET_PATTERNS:
repl = _keep_key(name) if mode == "keep-key" else _mask(name)
text, count = pattern.subn(repl, text)
if count:
hits.append(name)
return text, hits
def cap_text(text: str, limit: int | None = None) -> str:
limit = limit or max_content()
if len(text) <= limit:
return text
dropped = len(text) - limit
return text[:limit] + f"... [TRUNCATED {dropped} chars]"
def _clean_string(value: str, hits: list[str]) -> str:
scrubbed, found = scrub_text(value)
hits.extend(h for h in found if h not in hits)
return cap_text(scrubbed)
def scrub_attrs(attrs: dict) -> tuple[dict, list[str]]:
"""Walk an attribute tree: scrub every string, cap every string, and apply
the content kill switch to the keys that hold model-facing text.
Content keys always gain `<key>_length` and `<key>_sha256` siblings, so two
traces recorded under different settings stay comparable.
"""
hits: list[str] = []
keep_content = content_enabled()
def walk(value):
if isinstance(value, dict):
out = {}
for key, item in value.items():
if key in CONTENT_KEYS and isinstance(item, str):
out[f"{key}_length"] = len(item)
out[f"{key}_sha256"] = sha256_text(item)
if keep_content:
out[key] = _clean_string(item, hits)
else:
out[key] = walk(item)
return out
if isinstance(value, list):
return [walk(item) for item in value]
if isinstance(value, str):
return _clean_string(value, hits)
return value
return walk(attrs), hits
+184
View File
@@ -0,0 +1,184 @@
"""Appends trace events to `reports/telemetry/<session>/trace.jsonl`.
Two rules govern this module.
**Telemetry never breaks the tool.** `emit()` swallows everything: a full disk,
a permission error or a bug in a scrubber pattern must not turn a working
`wikitool` command - or a hook wrapped around someone's tool call - into a
failure. Tests call `write_event()` instead, which raises.
**Append, do not rewrite.** The budget state is a whole-file document and is
written with the temp-file + `os.replace` dance. A trace is append-only, so the
equivalent guarantee is `O_APPEND` plus an exclusive lock: several processes
write to one trace at once (the CLI in one, a hook handler per tool call in
another), and a line must never land inside another line.
"""
from __future__ import annotations
import json
import os
from pathlib import Path
from chemenu import config
from chemenu.session import session_id as current_session_id
from chemenu.session import session_slug
from chemenu.telemetry import schema, scrub
TRACE_ROOT = config.REPORTS_DIR / "telemetry"
TRACE_FILE = "trace.jsonl"
# Per-process counter. Not comparable across processes - see schema.py on how
# to order a trace.
_seq = 0
def enabled() -> bool:
return os.environ.get("WIKI_TRACE", "1") != "0"
def trace_root() -> Path:
"""`WIKI_TRACE_DIR` redirects the whole tree.
The eval runner gives each run its own directory the same way it gives each
run its own `VIBE_HOME`/`COPILOT_HOME`, so two runs cannot write into one
another's trace.
"""
override = os.environ.get("WIKI_TRACE_DIR")
return Path(override) if override else TRACE_ROOT
def trace_path(session: str | None = None) -> Path:
return trace_root() / session_slug(session) / TRACE_FILE
def _next_seq() -> int:
global _seq
_seq += 1
return _seq
def _traceparent() -> tuple[str | None, str | None]:
"""Read W3C trace context if the harness exported it.
Claude Code sets `TRACEPARENT` on the subprocesses it spawns while tracing
is active, so a `wikitool` call made from its Bash tool can record which
span it ran under. No other harness documents this today; the fields simply
stay absent there.
"""
raw = os.environ.get("TRACEPARENT", "")
parts = raw.split("-")
if len(parts) >= 4 and parts[0] == "00":
return parts[1], parts[2]
return None, None
def write_event(
source: str,
event: str,
attrs: dict | None = None,
*,
session: str | None = None,
run_key: str | None = None,
path: Path | None = None,
ts: str | None = None,
) -> dict:
"""Build, scrub, validate and append one event. Raises on a contract breach.
`ts` exists for post-hoc imports: a session reconstructed from a harness's
own store has to keep that store's timestamps, or it would sort as if it had
happened at import time.
"""
session = session or current_session_id()
trace_id, span_id = _traceparent()
clean_attrs, redactions = scrub.scrub_attrs(attrs or {})
# Before the record is built, so the header's `seq` stays lower than the
# event it precedes in the file.
target = path or trace_path(session)
target.parent.mkdir(parents=True, exist_ok=True)
if event != "session.start":
_seed_session_header(target, source, session)
record = schema.make_event(
source,
event,
clean_attrs,
session_id=session,
seq=_next_seq(),
run_key=run_key or os.environ.get("WIKITOOL_RUN_KEY") or None,
ts=ts,
trace_id=trace_id,
span_id=span_id,
)
if redactions:
record["redactions"] = redactions
errors = schema.validation_errors(record)
if errors:
raise ValueError(f"invalid trace event: {'; '.join(errors)}")
line = json.dumps(record, ensure_ascii=False, separators=(",", ":")) + "\n"
with open(target, "a", encoding="utf-8") as handle:
_locked_write(handle, line)
return record
def _seed_session_header(target: Path, source: str, session: str) -> None:
"""Open a new trace with a `session.start` naming what this source can report.
Without it, a trace from a harness that has no session hook - Mistral Vibe
has three hooks and none of them is one - would carry no `completeness` at
all, and a scorer could not tell "never happened" from "not observable".
The `x` mode elects a single writer: several processes append to one trace,
and an `exists()` check would let two of them both write the header.
"""
try:
handle = open(target, "x", encoding="utf-8")
except FileExistsError:
return
with handle:
header = schema.make_event(
source,
"session.start",
{
"harness": source,
"completeness": list(schema.HARNESS_CAPABILITIES.get(source, ())),
"synthesized": True,
},
session_id=session,
seq=_next_seq(),
)
handle.write(json.dumps(header, ensure_ascii=False, separators=(",", ":")) + "\n")
def _locked_write(handle, line: str) -> None:
try:
import fcntl
except ImportError: # pragma: no cover - non-POSIX platform
handle.write(line)
handle.flush()
return
fcntl.flock(handle, fcntl.LOCK_EX)
try:
handle.write(line)
handle.flush()
finally:
fcntl.flock(handle, fcntl.LOCK_UN)
def emit(
source: str,
event: str,
attrs: dict | None = None,
*,
session: str | None = None,
run_key: str | None = None,
) -> None:
"""Fire-and-forget. Records nothing and reports nothing if anything goes wrong."""
if not enabled():
return
try:
write_event(source, event, attrs, session=session, run_key=run_key)
except Exception: # noqa: BLE001 - telemetry must never break the caller
pass