Ctrl K
ComunidadGuías…

PRIVACY.md

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
# Privacy

`assaio-agent` is designed to be safe to run on a work machine. This document states
exactly what it reads, what it stores, and what it does not retain. If anything here is
inaccurate, that is a bug — please report it.

## What it reads

The agent reads local session logs written by AI coding tools:

- **Claude Code** — `~/.claude/projects/**/*.jsonl`
- **OpenAI Codex CLI** — `~/.codex/sessions/**` and `~/.codex/archived_sessions/**`
- **Gemini CLI** — `~/.gemini/tmp/<hash>/chats/session-*.jsonl`
- **GitHub Copilot CLI** — `~/.copilot/session-state/*/events.jsonl` (honors `COPILOT_HOME`)
- **Cline** — the extension's global storage (`saoudrizwan.claude-dev`) under VS Code, VS Code
  Insiders, VSCodium and Cursor, and `~/.cline/data/tasks`
- **Antigravity CLI** — `~/.gemini/antigravity-cli/brain/<id>/.system_generated/logs/transcript.jsonl`
  (`~/.gemini` is shared with Gemini CLI, and the two are read as separate sources). It extracts
  nothing this document does not already list — session ID, timestamp, tool-call and edit counts,
  the five tool-call purpose counts — and **less than every other source**: its format records no
  working directory, so nothing walks up to a repository root and neither a project nor a subpath
  is stored for it, and it carries no token counter at all. The sibling `transcript_full.jsonl`,
  which holds the same entries with more prompt text and no additional accounting, is not read.

It reads these files; it never modifies or deletes them.

Two files that are **not** session logs are read as well, both for one number each and neither
stored:

- `/Library/Application Support/ClaudeCode/managed-settings.json` on macOS, or
  `/etc/claude-code/managed-settings.json` on Linux — the machine-wide managed policy.
- `~/.claude/settings.json` — your own Claude Code settings.

`assaio-agent doctor` decodes exactly one key from the first of those that sets it,
`cleanupPeriodDays`, to state how long Claude Code keeps its own transcripts — which is the
real ceiling on how far back any report can ever reach. Nothing else in either file is decoded,
and the value is printed, never stored. A project's own `.claude/settings.json` is deliberately
not read: `doctor` answers for the machine, and one repository's setting is not the machine's.

If you configure exec plugins (`plugins:` in `config.yaml`), each one is a program you
chose that runs as a subprocess with your user privileges and reads whatever its own
code reads. Plugins are explicit opt-in from your config file only — `assaio` never
downloads plugins, never scans `PATH` for them, and never auto-discovers them. A
plugin's output is validated at the boundary before storage, is stored under a
`plugin:<name>` label, and is limited to the same usage-accounting fields listed below.

Metric plugins (`metrics:` in `config.yaml`) follow the same opt-in rules with one
difference in direction: `assaio` **sends** each one your stored usage aggregates on
stdin — project names, model names, member pseudonyms, and token/line counts, exactly
the fields listed below, never prompt, response, or repository content (none is stored) — so
it can compute its metric. That data goes only to the local program you configured;
know what a metric plugin does with it before declaring one.

What is sent is a **projection**, not the whole window: the plugin answers `describe` with the
sections it reads, optionally narrowed to named columns and matching rows, and receives exactly
that — a metric computing a token share is never handed the step timeline. Your `needs:` key in
the `metrics:` entry is a **veto** over that declaration, never its source: naming fewer
capabilities than the plugin asked for withholds the rest and says so in the envelope's
`withheld` field, and naming more grants nothing. Omitting the key sends what the plugin
declared. The fields listed below remain the ceiling on what any declaration can reach.

Rule plugins (`rules:` in `config.yaml`) are opt-in the same way and receive strictly
less: only the validator verdicts — titles, verdict labels, figures, caveats, and
takeaways — never usage rows, sessions, or prices. The ranked bar lists are **stripped
before the plugin is called**, because those are where project, skill, and sub-agent names
appear and a rule gates on a verdict, not on which repository produced it.
They run in `assaio-agent check` and can only emit alerts back.

## What it extracts

From each session log, the parsers extract only usage accounting fields:

- token counts (input, output, cache-read, cache-write, reasoning)
- model name
- timestamp
- session ID
- project — the basename of the session's **git repository root** (e.g. `webapp`),
  never the full path. At ingest, `assaio` walks up from the session's working
  directory to the nearest `.git` and keeps only that root directory's last path
  segment, so a monorepo's subdirectories (`apps/mobile`, `apps/web`, a worktree
  checkout, …) roll up into one project instead of fragmenting by leaf directory name.
  The full working-directory path is read only transiently to do that walk — it is
  never written to the store. If two transcripts assign the same completed sub-agent
  aggregate to different projects, neither name is retained for that record.
- repository — which repository root the record came from, as an opaque local key: an
  HMAC of the root's path, keyed by a random salt kept in the same database. It is stored
  once per repository in a `repository` table, and records point at it by number. It is set
  only when the working directory still existed and a `.git` was found; a worktree shares
  its main checkout's key. It exists so two repositories with one name are never counted as
  one project. It is never exported, synced, shared, sent to a plugin or printed —
  `assaio-agent repos` tells you which name a directory's usage is stored under when you
  name the directory. Unlike the step `target` below, it has to be a hash, because the same
  repository must be recognised in every later session; and because the salt sits in the
  same file, anyone holding the database can test whether a guessed path was one of your
  repositories. That is the exposure `ingest_file` (below) already has.
- subpath — the working directory's path **relative to the project's repository root**
  (e.g. `apps/mobile`), or empty when the session ran at the root. Always relative:
  never an absolute path, never the home directory.
- git branch name, when the log records it
- entrypoint label — how the tool was invoked (e.g. `cli`)
- granularity — whether a record is a single turn or a session-level aggregate
- AI line counts — lines added and removed, derived from the `+`/`-` markers of diff hunks
  or the line boundaries in a tool-recorded created or deleted file body. That content is
  read only to count lines and is never stored.
- edit and tool-call counts — how many file-editing tool calls (Edit/Write/…) and how
  many tool calls in total a turn made
- rejection counts — how many tool proposals the human declined
- sub-agent usage — a completed sub-agent's own token counts and its added/removed line
  counts, recorded as its own usage record
- compaction counts — how many times a session's context overflowed and got
  auto-summarized: a context-strain signal, not a content record
- rework line counts — a proxy for "AI wrote code that didn't stick," computed (for both
  Claude Code and Codex now) by matching, within one transcript, a later edit's removed
  lines against lines the AI itself added earlier **to that same file**. The file path is
  read only transiently, in memory, to group edits by file while parsing that one
  transcript, and is discarded the moment parsing finishes — **never stored.** Only the
  resulting numeric count is kept, same as every other field on this list.
- tool-call purpose counts — the same turn's tool calls split into how many read, searched,
  ran a command, wrote, or did something else. The tool's **name** is matched against a
  fixed allowlist while parsing and then dropped: only the five counts are stored, never the
  name, its arguments, or its output.
- tool error counts — how many of a turn's tool calls came back an error
- a sub-agent flag — whether the turn ran inside a sub-agent (`1` or `0`), read from the
  log's own marker

Two additional fields on this list are **tool-assigned text labels**:

- skill — the skill the tool itself attributed the turn to (e.g. `code-review`)
- sub-agent type — the kind of sub-agent the turn ran as (e.g. `general-purpose`)

Both are category labels the tool assigns, not anything you typed, and they exist so a
report can say where the spend went. They are names people choose, though, so a shared
report treats them exactly like project names: `--anonymize` (the default for the team
server and the published dashboard) replaces them with stable pseudonyms.

That is the complete list of what a usage record holds; the step timeline below adds its own,
and nothing else is stored. The record combines the identifiers and metadata named above with
numeric counts of how much AI produced, how efficiently, and with how much friction. No field
carries prompt text, model output, or code content: a diff line contributes a `+1` or a `-1`,
and a created or deleted file contributes its line count, then the content is discarded.

### The step timeline

Beside those per-turn records, `assaio` stores a session's **sequence**: one row per step,
holding only what kind of step it was (from a closed list: assistant turn, read, search,
command, edit, other, compaction), its position in the sequence, the model, its token cost,
and how it ended (from a closed list: ok, error, denied, truncated, or nothing when the log did
not say).

Two sources contribute one: Claude Code since v0.20.0, and Codex CLI since v0.26.0. The other
four store no sequence at all — not an empty one — because their logs record no order to read.
Codex's adds nothing to this list and states less of it: it marks how a step ended only for a
file edit, and only a patched file gives a step the integer described next.

One field there deserves its own paragraph, because it is the only one that stands for a file:

- **target** — an **integer** assigned in first-seen order within a single sequence, and
  nothing else. Not comparable across sequences: `3` in a session's main transcript and `3` in
  one of its sub-agents are unrelated. It exists so a sequence can show that the same thing was touched nine times.
  It is deliberately **not** a hash of the path: a hash is reversible by anyone holding the
  repository, because file paths carry almost no entropy. The path is read in memory while
  parsing one transcript, used to decide which integer to reuse, and discarded — the same
  discipline the rework counts already follow. `3` is not recoverable to a file name by
  anyone, including you.

  Since v0.21.0 the integer is read from the **call's own arguments** rather than from its
  result, which widens *which* steps carry one -- a read, and an edit that failed, now do -- and
  changes nothing about what is stored: still an integer, still per sequence, still no path. A
  path named relatively is resolved against the session's working directory before the integer is
  chosen, so one file cannot hold two of them; a relative path with no working directory to
  resolve it against is left unnumbered rather than guessed at.

How much of this history is kept is capped by `trace.horizon_days` (30 by default; `0` keeps
everything). `assaio-agent clear` erases the timeline under exactly the same scope as the
records it erases.

## What you add yourself

One thing in the store is not read from a log at all: the labels you attach with
`assaio-agent mark`. Session logs record what a tool did, never what the work was *for*, and
that intent cannot be recovered by reading prompts — which are never collected anyway.

A label is three **closed vocabularies**, and nothing else:

- task class — `bugfix`, `feature`, `test`, `refactor`, `docs`, `research`, `review`, `other`
- outcome — `done`, `partial`, `abandoned`
- difficulty — `low`, `medium`, `high`

There is **no free-text field**. A value outside its vocabulary is rejected, so the table is
free of content by construction: there is nowhere to put a branch name, a ticket title, a
commit message, or a prompt. Deliberately absent, too, is any issue or branch reference —
see [ADR 0006](docs/adr/0006-session-annotations.md) for why that belongs to attribution
work rather than here.

Labels are **local**. `sync` sends usage records; it does not send labels, and no other path
does either. They are also the only data in the store that no re-import can rebuild, so
`clear --all`, `clear --older-than` and `clear --tool` leave them alone and report how many
they kept — only `clear --labels` deletes them.

Labeling is optional and stays optional: an unlabeled session is counted in full by every
metric, a report grouped by a label always shows the `unlabeled` group rather than hiding
it, and nothing in `assaio` scores how much you label.

## A vendor export you point it at

`assaio-agent reconcile <file>` reads one more thing, and only when you name it on the
command line: a billing or usage export you downloaded yourself. It is read, never written
and never stored — the comparison happens in memory and only its result is printed. No
credential is asked for and no network call is made to fetch it; getting the file out of a
vendor console is your step, deliberately, so the tool never holds an API key that could
pull your account data.

## Local repository evidence

`assaio-agent evidence --repo <path>` and `assaio-agent survival --repo <path>` read a local
git repository only when invoked. The collector returns one content-free observation per
commit reachable from `HEAD`: commit hash, the time it was written (git's author time) and the
time it reached its branch, parent count, added/removed line counts, changed-file count, a
test/source/docs/config/generated/other category split, whether git itself gave the commit a
generated revert subject, and whether GitHub.com's own identity committed it. For that last fact,
git matches the committer itself and returns only commit hashes, so assaio reads no author or
committer name or e-mail.

Git paths are read in memory only to choose a category. The commit subject is read only to
recognize that revert prefix. Neither is returned, stored or printed, and branch names, diffs
and file bodies are not part of the observation at all. Commit observations and derived
session→commit results are recomputed in memory and never written to SQLite.

`evidence` combines those observations with the local store's session id, tool, project
name, the repository the session's rows resolved to (read as a local number and never printed)
and first/last timestamps. Its text and JSON output include the project name — empty when no
stored usage resolved to the repository — the session id and the commit hash for local
inspection, plus method, confidence, provenance, ambiguity, alternatives and coverage. It has no `--db`, refuses member-bearing team rows and
has no member, person, score or rank field. It is not a shareable artifact and is not intended
for evaluating people. Prompt text, model responses, code, diffs, commit messages and branch
names never enter its result.

### `evidence --github`

Only with `--github`, `evidence` runs your own GitHub CLI (`gh`) in the repository. `gh repo view`
names the repository and its host. Two fixed GraphQL queries walk PRs independently, ordered by
last update, newest first. The base walk reads 50 PRs per page for at most 20 pages; the historical
walk reads 5 PRs per page for at most 20 pages. Each walk can additionally re-read at most 20 pages
from the top. `--since` bounds the update-time walk, not every event's occurrence time. Each API
call has a 60-second timeout and an 8 MiB response cap. Source errors fail the document; no
resource-limit partial result is accepted.

The base read includes each PR's id, number, state, update and merge times, merge commit hash and
parent count, and first 100 listed commit hashes with their total. It reads at most the last 100
reviews' opaque ids, closed states, reviewed commit hashes and submitted or updated times.
Actual submission times are retained separately from an observation's occurrence-time fallback.
It also reads the latest head's check rollup and at most the last 100 check-run or commit-status
contexts' opaque ids, closed states or conclusions and times, with total and listed counts.

In the same base query, each PR adds the last 100 `REVIEW_DISMISSED_EVENT` timeline items.
The exact event allowlist is `__typename`, `id`, `createdAt`, `previousReviewState` and nullable
`review { id submittedAt commit { oid } }`. Coverage reads `totalCount` (all timeline events),
`filteredCount` (selected dismissals), `pageCount` (source-reported slice count) and
`pageInfo.hasPreviousPage` (selected truncation); `listed` counts actual retained nodes locally.
Missing coverage stays null/unavailable, while independently validated raw events can still appear.
Actual dismissal `createdAt` is required with no fallback. Events share the invocation
`ObservedAt`; a null review leaves its id, reviewed hash and submission time absent.
No author, actor, reviewer, `dismissalMessage`, title, body, content or person field is selected.
Raw `scm.review.dismissed` observations describe PR outcomes, leave current review snapshots and
matching unchanged, and add no rate or count-derived metric. They remain in memory for one local
command, outside SQLite, sync, dashboards, share and plugins
([ADR 0025](docs/adr/0025-bounded-review-dismissals.md)).

The separate historical read covers the first 100 currently listed commits, the last 10 check
suites per commit and the last 5 check runs per suite with check type ALL. It reads content-free
identifiers, commit hashes, closed status/conclusion fields, source timestamps and connection
counts. Both walks share one invocation `ObservedAt`; their read-back windows and PR counts remain separate. Null connections,
counts or nodes mean unavailable; an explicit empty connection with total zero means known empty.

Neither query reads reviewer, author, title, body, comment, check or workflow name, branch or
output. Requests name the repository and a page cursor, never a commit, session or anything from
the store. `gh` holds your credentials; assaio reads, passes and stores no token. assaio then asks
local git which listed commits a `HEAD` reflog of this clone records as made here.
Connector observations and links stay in memory for one command: they are never persisted to the
store, synced, rendered on a dashboard or sent to a plugin. A caller can save the explicit
`evidence` output. The repository's owner and name appear only in a note on standard error, never
in that output. The queries run as you, so they can appear in your organization's API audit log.

## What it never retains

- Prompt text
- Model responses / generated content
- The **content** of any file, diff, or code from your project — recorded diff and file-body
  content is used transiently for line and rework counts, then discarded
- Anything in a session log beyond the usage-accounting fields above

The parsers necessarily scan each log line. They decode the documented fields plus transient
diff and file-body content needed to derive line and rework counts. Neither that content nor
message bodies are placed in a usage record or the database.

## Where your data lives

Normalized usage is stored in a single embedded SQLite database:

```
~/.local/share/assaio/assaio.db
```

The location honors `XDG_DATA_HOME`. This file never leaves your machine.

Beside the usage records, the same file holds three small bookkeeping tables. None holds
usage, and each exists only so a repeat command stays cheap and honest:

- `ingest_file` — one row per input already parsed: its **path on your disk**, size,
  modification time, and which build read it. This is the only place `assaio` stores a
  full local path, and it is never synced, exported, or shown in a dashboard. Rows for
  files that are no longer on disk are dropped on the next pass.
- `ingest_source` — one row per source per `backfill` run, holding only counts (files
  found, files read, records, skipped lines, records carrying no tokens) and a timestamp.
  It is what the format-drift canaries compare against, and only the newest runs per tool
  are kept.
- `digest_snapshot` — one row per `digest` run, holding the verdicts and totals that run
  reported plus the build that parsed them, so the next digest can say what *moved*. A few KB
  each, never usage rows, and all but the newest few are deleted whenever one is written.

All three can be deleted at any time; the only cost is one slow re-parse, a reset drift
baseline, and a digest with nothing to compare against.

A fourth table, `session_label`, holds the labels described under "What you add yourself" —
one row per session you marked, carrying only the three vocabulary values and a timestamp.
Unlike the three above it is **not** a cache: nothing can rebuild it, so it is the one table
`clear` never removes without being asked. Its size is bounded by how many sessions you
marked by hand (~80 bytes each), not by how much you have ingested.

Two more tables hold repository identity: `repository`, one row per repository root seen —
its name, its key and the number it is shown with when the name is shared — and
`repository_salt`, the 32 random bytes the keys are derived with. Neither leaves this
machine. `clear --older-than` and `clear --tool` keep both, so a repository keeps its number;
`clear --all` empties `repository` and replaces the salt, so no repository name or keyed hash
of a path remains.

## How to delete it

```sh
assaio-agent clear --all --yes
assaio-agent compact          # actually return the freed space to the filesystem
```

`clear` refuses to run without an explicit scope (`--all`, `--older-than`, `--tool`, or
`--labels`) and the `--yes` confirmation flag. The first three delete usage records and keep
your session labels, reporting how many they kept; `--labels` is the deliberate way to delete
those too. `--all` also deletes the repository names and keys described above, and deleting the
database file removes everything at once. Note that deleting rows frees pages *inside* the
database file without shrinking it — `compact` is what returns that space to your
filesystem. You can also simply delete the database file. Because all data is local and
self-contained, this makes `assaio` straightforward to operate under GDPR-style deletion
requirements.

## Network

The core analysis commands — `backfill`, `report`, `effectiveness`, `analyze`, `status`,
`dashboard`, `share`, `reconcile`, `evidence` without `--github`, `survival` — make **no network calls**. The model price table is embedded into the binary
at build time, so every report works fully offline; nothing is fetched, uploaded, or
phoned home.

Three **optional commands are the exception**, and only when you invoke them.
`assaio-agent sync` uploads your usage records to a team server, and `assaio-agent serve` runs
that server; both talk only to infrastructure **you** stand up and point them at (see below).
`evidence --github` is the only one that reaches a third party: your own `gh` asks
GitHub for the repository's pull requests ([what it asks](#evidence---github)). If you never run
those three, `assaio` itself never touches the network, and exec plugins (described at the top of
this file) are your own programs.

`assaio-agent share` is not an exception to this — it makes no request, and neither does the
page it writes. After writing the file it can ask your desktop to open it (`open`, `xdg-open`,
`rundll32`), which launches your browser; `--no-open` writes the file and stops.

## Sharing a dashboard, and sharing an assay

`assaio` writes two shareable artifacts, and their guarantees differ. Read this section before
generalizing from one to the other.

`assaio-agent dashboard` writes a single self-contained HTML file — all styling is inline,
with no external fonts, scripts, or requests, so it renders offline and phones nowhere.
Because that file is meant to be shared, it **pseudonymizes project names by default**
(a stable `project-xxxx` label); pass `--no-anonymize` to keep real names. The interactive
CLI tables always show real *project* names — the setting governs only the shareable export.
Member names are stricter and not governed by it: `report` renders a pseudonym in every format,
and `--identify` exposes the stored member value: a legacy name or a keyed v2 digest (see below).

`assaio-agent share` is the artifact built *for* publication, and its redaction is
**structural rather than a flag** — there is no `--no-anonymize` equivalent, because no field
it renders can hold a repository, member, path, branch, skill or sub-agent name. Repositories
appear only as a count, labelled *never named*; an out-of-tree parser's tool label collapses to
`a plugin source`, since that name comes from your own config rather than from a vendor. Tools
and models **are** named, deliberately: which coding agent and which model ran is a fact about a
vendor, not about you. The preview page is self-contained — inline CSS, one inline script, no
external font, image or request — and the PNG, MP4 or WebM it offers are produced in your own
browser from a canvas and saved by your own download. Nothing is transmitted at any point.

Since v0.28.0 the card also carries a week-over-week block, and its note is dated. It names the
two UTC week ranges compared and, where a direction is stated, the two volume sums behind it.
Where none is stated, it gives the reason in the words every surface uses, and some of those
reasons carry a date from your own machine: the day your store's history begins, or the day a
source was last read. It also says how many sources the sums leave out and what share of the
volume they held. None of that is a name and none of it is a path, but all of it describes your
store rather than a vendor — and it is on the image, so read the note before you post it.

## Telemetry

None. No usage pings, no analytics, no crash reporting. The agent does not know we
exist and does not tell us it ran.

One distinction worth stating, because a card makes it look otherwise: a card you choose to
post carries the project's hashtag and its install line **on the image**, deliberately, so a
re-shared image keeps them. That is a caption `assaio` wrote, not a signal it sent, and it
travels only if you post it.

## The optional team server

The optional, self-hosted **team server** pools usage on infrastructure you control:
`assaio-agent serve` runs the collector and `assaio-agent sync` pushes records to it. This is
the networked exception to local analysis. The server has no TLS or user roles; use a TLS
reverse proxy and a trusted network. Every route except `/healthz` requires a bearer token,
including the dashboard. A shared `server.token` can read the dashboard but cannot write sync
v2 usage. Writes require a separate per-member token configured under that member's digest in
`server.members`.

Sync v2 sends POST `/v2/usage` with protocol 2, a stable `member-v2-` digest and allowlisted
usage fields. Its wire format excludes `GitBranch` and cleartext `Member`; the server refuses
unknown fields. The client derives the digest with HMAC-SHA256 from local `--member` or
`sync.member` input and a private `ASSAIO_SYNC_IDENTITY_KEY` (32 bytes encoded as 64 hex
digits). Neither the input nor the key is sent to the server. Keep and back up the key on
clients; devices representing one member need the same key and stable input. A lost or rotated
key changes the digest, with no automatic rekey procedure.

`sync` still sends `Project`, `Subpath`, the coding tool's `SessionID`, timestamps and other
allowed usage fields. It never sends the local repository key, pull-request numbers, commit
hashes or session→commit results ([ADR 0020](docs/adr/0020-correlation-privacy.md)). A server
reader with repository or telemetry data may join those fields to a person or piece of work.
The digest is a pseudonym, not an anonymity guarantee. When two local repositories share a
project name, `sync` warns before pushing because the server pools usage by that name. The
served dashboard anonymizes member labels; below five distinct synced members it also hides
per-member session rows and shows an aggregate session distribution. The underlying records
remain available to a server operator.

An existing central database with synced member rows requires an offline migration before v2
writes. Stop `serve`, back up the database, prepare a complete one-to-one map from old stored
member labels to locally printed v2 digests outside the repository, then run
`assaio-agent serve migrate-sync --db /path/server.db --map /private/path/mapping.json`.
The map is not sent or stored in the database; its local file remains until you remove it.
The migration clears branch values from synced usage and archive rows, but old backups,
SQLite free pages and WAL files may retain old values. Historical collisions in old 40-bit
member labels cannot be separated. Do not run an older server binary against a migrated
database.

Reading a central store with `report` pseudonymizes member labels in table, JSON and CSV;
`report --identify` exposes the stored label, which is a keyed digest for v2 rows, not the
original local member input. A metric plugin receives pseudonyms rather than local names.
The deeper stage — correlating with git remotes and issue trackers for survival, bug and
quality signals — is still ahead; see [ROADMAP.md](ROADMAP.md).