Imported from gitroll-dev/deident (
skills/deident/SKILL.md). Install upstream withnpx skills add gitroll-dev/deident --skill deident. Copyright stays with the author.
name: deident description: De-identify the user's own Claude Code session logs and pack them into an archive they can hand to someone else. Reads Claude Code's own log layout by default, and Codex, Cursor, opencode or Gemini CLI logs when told which with --agent and pointed at them with --root, since it has no default location for those four and will not guess one. Cursor and Gemini CLI record no working directory, so their logs can be read and listed but not exported. Use when the user asks to export, share, hand over, submit, donate, anonymise or de-identify their session logs, transcripts, conversation history or coding history, when a colleague has asked them for their logs for a benchmark or an evaluation, or when they name deident. Match on what is being asked for rather than on the wording: the request arrives in whatever language the person speaks, and a translation of any phrase above is the same request. Drives the whole flow: survey, decide what leaves, redact, export, verify. Not for exporting someone else's logs and not for ordinary file archiving.
deident
Turn a machine full of AI coding-agent session logs into one archive that is safe to hand to a named recipient, and prove it.
The tool does the mechanics and refuses when a check fails. You do the two things it cannot: read prose for identities a machine cannot find, and put a decision list in front of the person.
Before anything
Everything below is one CLI. Find it once and reuse the path:
node <repo>/deident.js --version
<repo> is wherever this plugin's repository is checked out. If the command
prints a version, you are ready. If it says the Node version is too low, stop
and tell the person the version it named; nothing else will work.
Three rules that hold for the whole flow:
- Never pass
--include-denied,--skip-unclassified,--skip-unknown-typesor--declare-nothingunless the person has just asked for that specific thing. Each one turns off a refusal that exists because something went wrong once.--declare-nothingis the sharpest of them: it is the person's own answer about their own identity, it is recorded as theirs, and typing it for them puts back the gap that shipped 21 identity fields. Ask, and if they have values, help them write the file instead (below). - Never write into the repository. Use
--out <somewhere else>; the files this produces carry real paths and real names. - Add
--jsonto every command. You get the same values as structured data instead of aligned columns, including on a refusal, where the exit code is inside the document.
What carries between runs
Everything the tool remembers is in one directory, ~/.deident-private, which
--salt-dir overrides:
salt: makes one person get the same pseudonym in every run. Delete it and everybody is renumbered.entities.json: the identities already declared, plus a hash of every session already put in front of a reader. This is what makes a repeat run cheap, and it is the file a person may edit by hand.workspaces.json: the tier decisions, so scanning into a new working directory does not lose them.denied.json: the person's OWN deny rules, on top of the shipped ones.known-values.json: values the person has DECLARED are theirs. The one source in the tool that is not a guess, and the one file you can help build. See below.occurrences.json: the occurrences the last export replaced. Read withreview --entity, below.
Everything else (review.md, the candidates file, the archive, export-map.txt)
lives in --out and belongs to one run. Inside --out, the archive is alone in
send/ and everything else is outside it: send/ holds what may be sent, the
top level holds what may not, and WHAT-TO-SEND.txt says so file by file.
To run as if for the first time, give every command in the flow a fresh
--salt-dir and a fresh --out. That discards the pseudonyms, the declared
identities and the read-session record together, which is the point.
Copy denied.json and known-values.json into the fresh salt directory
first. They are the two files whose absence is silent and dangerous. Without
denied.json none of the person's own deny rules load, so a directory they
expect to be excluded is proposed at redact and offered for export, with every
check green, and no check knows a rule was supposed to exist. Without
known-values.json every value they declared as their own goes back to being
something a reader has to spot in the prose.
cp ~/.deident-private/denied.json ~/.deident-private/known-values.json <fresh-salt-dir>/
deident warns when the salt directory in use is missing either one and the default one has it. If you see that line, stop and copy the file; do not read past it. A machine with neither file anywhere is a genuine first run and gets no warning.
known-values.json: the one file you can help build
Everything else deident knows is inferred. Tier 0 infers from the machine (the username, paths, git config, credential shapes) and tier 1 infers from the prose (whatever a reader can see). Neither can be TOLD "this exact string is mine".
Measured on a finished export whose checks were all green: 21 identity fields shipped in plaintext. Document name spellings, a date and place of birth, six addresses in two languages, a phone number, a payment account id. Most of them came out of one browser-automation session that had been filling a booking form, and every one of them was already written down in a personal-details file the same person maintained by hand.
{
"_note": "Values that are mine. Local only. Never commit this, never share it.",
"values": [
"1974-11-03",
"Flat 6B, 219 Marlowe Crescent, Ashford Bay",
{"kind": "person", "value": "Aurelio Ferreira-Nkemdirim"},
{"kind": "account", "value": "pm-8842-31770"}
]
}
A bare string is enough. kind is optional and changes only which pseudonym the
value gets. A malformed file refuses the run and names the row. A missing one
refuses the export outright: the operator has to say something, either by writing
the file or by running deident export --declare-nothing once, which writes it
with an empty list and the date. The manifest then says which of the two
happened, so a reader of the archive can tell "declared nothing" from "declared
and it was applied".
Ask whether they already keep their own details in a file somewhere. Many
people do: a profile JSON they fill forms from, a notes page of passport and
address details, an onboarding document, a form they have submitted before. It
lives somewhere different on every machine, so ask where rather than guessing a
path. If one exists, turning it into known-values.json is the single highest
value thing you can do before an export, and it is one pass over one file.
If they have no such file, ask them directly instead. Names as written on documents rather than as written in git config, dates of birth, addresses they have lived at, identity document numbers, account handles at the services these sessions touch. The list does not have to be complete to be worth having.
Write it into known-values.json and stop there. Do not copy their
personal-details file into the working directory, do not repeat its contents
back in your own output, and do not put a value from it in any file but this
one.
After the export, the run prints every declared value back with the number of times it was replaced. A count of zero means a typo in the list or a value that is genuinely not in the corpus. A count in the hundreds means a word that is also theirs, which is a fact for them to act on and not an error.
--full is a different question and a much smaller hammer: it re-reads the
whole corpus for the semantic pass while keeping the salt and the declared
identities. Use it when the person has changed their mind about what counts as
an identity. A fresh salt directory is for starting over completely.
1. Survey
node <repo>/deident.js scan --out <workdir> --json
This writes review.md in <workdir> and changes nothing else. The JSON
carries workspaces (one row per directory the sessions ran in, with a proposed
tier) and sessions (one row per session, with a keep or drop decision).
Read the counts back to the person. A corpus of a few hundred sessions is normal.
2. Decide what leaves
This is the step that matters and it is the one you must not do alone.
A workspace tier is exclude or redact. A session row is keep or drop.
The proposals are derived from signals the tool can read; the person corrects
them.
Present a decision list, not the data. One line per held-back session with one line of reason. The person answers a list; they do not read transcripts.
drop means held back, and it is the only way a session is held: someone
else's identity documents, health, private messages, live credentials, or
anything else the person will not send. Getting this wrong in the safe
direction costs nothing; a row you cannot classify is a drop.
Nothing but the floor moves these rows. Never hold a session back on the grounds that the archive is going out publicly: that is what substitution is for, and removing whole sessions is the measured way this tool used to lose most of its corpus.
Write decisions back by editing review.md in place: column 1 of the
## workspaces and ## sessions sections.
Say what the export cannot protect, and do not ask who it is for
Do not ask who the archive is for. There is no setting for it and no
decision to take: the person's own employer name and product words are
substituted along with everything else, on every run. That is the safe reading
of every recipient, and it costs no sessions. The cost is prose quality, where a
sentence reads ORG_4471 and a colleague would have read a word they know,
which is not worth stopping the person to ask about.
Say both of these, here, before anything expensive runs. Neither is fixed by any setting, and they are the reason someone might decide not to export at all, which is a decision worth reaching before the work rather than after the archive exists:
- Substitution replaces names, not roles. "The person who runs finance and payroll" resolves to one human at a small company however it is spelled.
- A published work log shows the domain they work in and the kind of client they work for, whatever the names are replaced with.
3. Triage: cut the list before anything expensive reads it
node <repo>/deident.js triage --out <workdir> --json
That writes deident-triage.txt: one block per session your review still
proposes to keep, carrying the session id, its date, the workspace, and the
first thing the person typed truncated to 300 characters. Nothing else, and only
the head of each session file is read.
It is the first prompt that says something, not literally the first. A session
opening with /clear or /model used to show you the command envelope and
nothing else, and one of the sessions that shipped 21 identity fields opened
exactly that way. Measured 2026-08-25 over 214 live sessions, 45 have a
contentless first prompt and 15 of those are answered by the very next prompt in
the same head. Where the prompt you are reading was not the first, the row says
so above it. Do not read the opening of a session into a prompt that arrived
after a context reset.
A row that reads (nothing to judge: ...) is a session nobody can see into: 30
of the 214 are in that state. The rubric in the file already answers those, and
its answer is drop.
Measured 2026-08-24 on a 205-session corpus: 23 KB, about 7k tokens, against 915 KB and about 250k tokens for step 4. That 35x is the whole reason this step exists, and it is why it runs before step 4 rather than after.
Read it and write deident-triage.json beside it:
{
"verdicts": [
{ "id": "<session id>", "verdict": "drop", "reason": "one line" }
]
}
verdict is drop or unsure. There is no keep. A triage verdict may
only ever move a session toward drop: the tool refuses a keep naming the row
it came from, and a verdict cannot overturn a session that is already dropped.
Both are enforced in code. unsure means "I looked and I am not acting", and it
exists so a considered row does not look like a skipped one.
A low-tier model is appropriate here, and this is the one step where that is
true. docs/model-tier.md disqualifies the low tier for step 4 because its
failures are misses and a miss there is a disclosure. Here the only power on
offer is removal, so a wrong verdict costs coverage and never privacy. Use the
rubric in the file's own header rather than a stricter one of your own.
Then apply them:
node <repo>/deident.js triage --apply --verdicts <workdir>/deident-triage.json \
--out <workdir> --json
That writes drop into column 1 of review.md, puts the reason on the row, and
remembers the drop beside the tiers so a later scan elsewhere does not lose it. A
verdict naming a session that is no longer in the corpus warns and is skipped;
sessions get deleted between runs.
deident-triage.txt carries raw prose. Unlike deident-candidates.txt, tier-0
substitution has NOT run over it, so handing it to a model sends untouched
session text to that model. That is a real cost, and it is why the payload is one
truncated line per session rather than a transcript. Never commit it, and never
paste it into a ticket.
4. Find the identities a machine cannot
The export refuses without this. Run:
node <repo>/deident.js export --out <workdir> --preview --json
That writes deident-candidates.txt: the session prose after the tool has
already replaced usernames, paths, git identity, git remotes, emails and MCP
server names. What remains is what needs a reader.
A repeat run reads far less than the first one. What the person declared is
remembered in ~/.deident-private/entities.json, beside the salt, along with a
content hash of every session that has already been put in front of a reader.
So this file carries only the sessions that are new or that changed, and says
so in its own header. Measured on a synthetic 60-session corpus with three
sessions added later: 211.0 KB on the first read, 12.2 KB on the second.
Three things follow, and the person should hear all three:
--entitiesis optional once the dictionary exists. Absent, the dictionary supplies the list. Present, the file wins on the identities it names and the dictionary supplies the rest, so a short list written about three new sessions does not drop the forty identities the earlier runs established. To remove an identity on purpose, edit the dictionary.- The dictionary is a plaintext file the person may edit by hand. Adding a
spelling, or deleting an entry that was wrong, is an ordinary thing to do and
the file states its own rules at the top. It is local only, it pairs real
spellings with real session ids, and it must never be shared or committed:
treat it the way you treat
review.md. --fullre-reads the whole corpus. Use it when the person has changed their mind about what counts as an identity, not routinely. It refuses the export, writes the completedeident-candidates.txt, and the next run supplies the list as usual. It cannot be combined with--entities.
If the export refuses naming sessions that "have not been through a semantic pass", that is not a bug and not a corpus problem: those sessions are new, or their content changed since they were read, so they are the ones now in the candidates file. Read them, add anything they turned up to the entity list, and run the export again.
If a row is marked (written N minutes ago), that is a session somebody still
has open, possibly the one you are in. Reading it again will not clear it: the
hash is over the whole session, so every turn added to it changes its prose
back and the same refusal returns. Close that session, or leave its workspace
out at the review step, then run the export again.
The candidates file is one BATCH, not necessarily the whole backlog. It is
capped at 120,000 characters per run (--batch-chars <n> to change it), and
only the sessions actually written into it are recorded as read. When the file
says sessions were deferred, supply your list, run the export again, and the
next batch arrives. Do not treat one pass over this file as covering the corpus.
It is a PROSE extract, and the export substitutes over everything it keeps, so the file shows you less than what ships. Measured 2026-08-24: a name-part check over the candidates file found 8 uncovered surnames and the same check over the export found 17. Step 6 is where that gap closes, so do not treat this file as the whole surface.
Every UUID in it is already a pseudonym: session and message ids were replaced before it was written. Do not declare one. Doing so made the export refuse against deident's own output.
Read it yourself. Do not hand this step to a cheap subagent. Measured across
three model tiers on one corpus (docs/model-tier.md): the low tier found 0 and
1 of the seven values that were themselves the secret, while filing Delaware,
Baltimore and SFO as identities and marking almost nothing low-confidence. It
returns a full-looking list of 27 entities either way, which is why the failure
does not announce itself.
Read it and write deident-entities.json beside it:
{
"generated": "<ISO timestamp>",
"entities": [
{ "kind": "person", "spellings": ["Ada Lovelace", "AdaLovelace", "Nora"], "confidence": "high" },
{ "kind": "org", "spellings": ["Acme Advisory"], "confidence": "low" }
]
}
kind is one of the kinds listed in the candidates file's own header. Read it
there rather than from this document: the header interpolates the live list, and
a copy in prose drifts.
What goes in the list:
- Every named human: colleagues, clients, accountants, candidates, family. Third parties never consented, so include them; do not ask which to skip.
- External organisations, banks and services tied to an account.
- The user's own employer: its written-out name, its products and its internal service names. Those words alone say where the person works and what it sells, and the written-out name has no tier-0 source, so this list is the only thing that can carry it.
- Real host names.
- Every spelling you actually see for one identity, in ONE entity's
spellingsarray: full name, given name, surname, the run-together forms that appear in handles and filenames, other scripts, and dictation errors. One identity per entry, or one person gets two pseudonyms and the prose stops making sense.
What stays out:
- Generic technology and platforms mentioned as technology.
- Ordinary words. This is the one that has actually gone wrong: a common
noun was declared once and replaced 202 times across a corpus that had already
been delivered, with every gate green, because a reversible wrong replacement
is still reversible. If a spelling is a word someone might write by accident,
leave it out or mark it
"confidence": "low". - Values, unless the value itself is the secret. A figure, a balance or an
account number belongs here as
kind: "secret"; a sentence does not.
Set "confidence": "low" whenever you are guessing. Low-confidence entries are
listed individually for the person rather than collapsed into a count.
The session you are running from will refuse
The corpus includes the session you are working in right now, and reading it appends to it, so its content hash changes and the coverage gate refuses it. Measured on a real run: the gate refused twice for that one session, once after each read, and nothing the reader does can settle it.
Mark it drop in review.md. It is the session in which the export was
built rather than a sample of the work, so dropping it costs the recipient
nothing. Do not reach for --full: that re-offers the whole corpus and does
not fix this one session, which will change again while the reader is reading.
5. Export
node <repo>/deident.js export --out <workdir> --json \
--entities <workdir>/deident-entities.json \
--namespace <two letters nobody has used yet>
Time it against your own corpus, not against a number written here. Two measured points, same machine: 219 session files gave scan 22s, triage under a second, preview 15s, export 14s. A 2.3 GB store of 4,264 files gave a 7m25s export. It scales with bytes read, and the tool_result surface it reads and discards is most of them, so a large store costs minutes rather than seconds. Under a minute, wait on it. Above that, background it and poll, because a command timeout that kills the export mid-write leaves nothing behind. The stage that costs a PERSON anything is still the one where a reader reads, and that one is not a command.
--namespace needs a fresh value each run. The tool prints its namespace, the
terminal is logged into the session, and the session is part of the next run's
corpus, so a namespace used before will collide and refuse.
6. Read the report before saying it worked
The JSON document carries checks. Every one must be ok. Two of them are
worth naming to the person:
known-entity residue: zero occurrences of everything the table knew.archive on disk: the same scan, over the file that was actually written. This is the only check whose subject is the artifact the recipient opens.
All of them ask one question, and it is smaller than it looks. Every check
compares the output against the entity table it was given. Not one asks whether
the table is complete. The terminal says this in one line for that reason; the
JSON keeps the rows so you can name the failing one. Do not report a passing
checks array as "no identities leaked". Report it as what it is: everything
in the table was substituted, everywhere the scan found it.
unverified: what none of the checks covers, on this run.proseBytesis the prose a reader was shown,archiveBytesis what leaves,unreadPercentis the rest, andtoolParamPercentis the slice of that rest which is free text. CarrytoolParamPercentinto the handover, notunreadPercent. Most of the non-prose remainder is record scaffolding and identifiers deident minted, which cannot hold an undeclared name;tool_useparameters can, and no reader is shown them. Quoting the larger number would overstate the gap as badly as quoting neither would understate it.declaredResidue: values fromknown-values.jsonthat never entered the entity table, swept for anyway. The needles are re-read from the file on disk rather than taken from the run's table, which is the point: a value the safety rules refused is not in the table, so every other check is blind to it by construction.rowsis per value with a real occurrence count, and a count above zero means that value is in the archive unreplaced. Not a failure and it will not refuse: the person declared a value the tool already told them it cannot safely substitute. Report the row and let them decide. These rows carry real values, so treat them the way you treatdeclaredValues.
Also carry back:
manifest.heldByFloor, which is the whole held count: nothing else holds a session back.- Whether the employer's own name is in the entity table at all. On a machine with no git remote nothing seeds it, and its absence is a GAP rather than a clean bill: the written-out name has no tier-0 source, so only the entity list from step 4 can carry it.
replacementCounts.hits: how many times each spelling was replaced, highest first. Read the top rows. A common word near the top is a false positive that every gate will pass. A workspace path or the user's own name at the top is expected.replacementCounts.zeros: spellings a person supplied that matched nothing. Spellings deident generated are not in this list, so every row is one somebody actually wrote. ReadmatchedAson each row, because it splits them into two different findings:matchedAsis a string. The entity WAS replaced, through a different spelling. The person wrote a form this corpus does not use. Harmless for a path typed with the other separator. Not harmless for a name or a company, which is the shape a delivered leak had: the declared strings were Traditional Chinese and the corpus wrote them in Simplified. Tell the person which form this corpus uses, so the rest of their list matches it.matchedAsisnull. Nothing of that entity matched, through any of its spellings. Usually a typo in the entity list, sometimes a value that genuinely never came up here. It does NOT mean the string is absent from the corpus: if the same identity is also in the list as a separate entity, that one may already be covering it. Not an error on its own.
declaredValues: every value fromknown-values.json, with what each one actually replaced. Not a subset and not a verdict. Two rows need the person: a count of zero, which is a value the corpus never contained or a typo in their list, and a value flaggednever substituted, which is one they asked for and did not get because it is too short or too collision-prone to substitute safely. A high count is not an error. Report it and let them decide: it is a value of theirs that is also an ordinary word, and only they can tell those apart. These rows carry the real values, not the pseudonyms. Treat them the way you treatreplacementCounts: report the verdict, never paste the rows into a ticket, a chat or a commit.
When a count looks wrong, drill into it rather than guessing. Each hit row
carries a pseudonym; pass it back:
node <repo>/deident.js review --entity <PSEUDONYM> --salt-dir <same> --json
That prints every occurrence with the session it was in and the text around it,
which is the only thing that separates a name replaced 991 times from an
ordinary word replaced 991 times. review --session <id> then prints one full
transcript, read back out of the archive. Both refuse if no export has run.
This output is a re-identification key and it must not leave the machine.
The excerpts are the text BEFORE substitution, so they carry the real names
beside the pseudonyms that replaced them. Treat it exactly as you treat
export-map.txt: never paste it into a ticket, a chat or a commit. Report the
verdict to the person ("those 991 are the client's name" / "those 202 are the
ordinary word for meeting"), not the rows.
uncoveredNameParts: pieces of a spelling you declared that still stand alone in the text.Grace Hopperreplaced and a bareMorganleft behind is a half replacement, and no check catches it: the residue scan only looks for what it was given. The same shape reaches every other kind through multi-word spellings. An office address declared as one comma-separated string shipped its street on its own, because only the whole string was ever a needle. A single word is proposed only from aperson; from any other kind only a contiguous run of two or more words is, which is what admits the street out of an address without ever proposingRoad. Add the ones that really are that entity and re-run. Leave out any that are ordinary words.gluedResidue: occurrences of the person's own username or git identity that are still in the archive, joined to letters or digits (yourname-prod,kv-yourname01234). The substituter refused these on purpose: the word boundary rule cannot tell them from a name sitting inside an ordinary word, and BRIEF ยง4.5 requires that non-match. Not a bug and not a failed check. It is a decision to hand back. Each row carries a count and an excerpt. Say so plainly: renaming the resource before exporting is one fix, declaring the glued spelling itself in the entity list is another, and accepting it is a third.manifest.gluedOccurrencesis the same finding as a single count. A spelling shorter than five characters is a row only where nothing alphabetic is glued to it (kv-lok01, an identity-document filename); where a letter is, the occurrences are withheld, because at that length the list is mostly ordinary words (rayinsidearray).manifest.gluedNotListednames those spellings with their counts. An emptygluedResiduebeside a non-emptygluedNotListedmeans not examined, not clean.
If the export refuses, the JSON has ok: false and an error with reason,
why and runnable remedies. Act on the remedy; do not retry the same command.
7. The cold read
Every check in step 6 compares the archive against the entity table. Nothing
compares it against a person. Both leaks this tool has actually caught were
caught by oracles outside it: a grep of the shipped bytes, and a diff against a
maintained identity file. known-values.json imported the second one. This step
is the third, and it is the only one that can find a name nobody declared.
Run it after the archive exists and before anyone sends it. It costs one subagent call.
What to hand over. Three sessions, read back out of the archive, not out of the corpus:
head -3 <workdir>/export-map.txt # pick three entry names (outside send/)
node <repo>/deident.js review --session <id> --salt-dir <same>
Take the first ~2,000 words of each. Prefer the three largest entries: a short
session carries too little to identify anyone and a pass on it proves nothing.
Hand over that text and nothing else. No file paths, no entity list, no
export-map.txt, no summary of what the sessions were about.
Who reads it. A reader who does not already know the answer. This is the whole test and it is the easy thing to get wrong: you have been staring at these names for an hour, so you cannot run it, and neither can anyone else in the conversation that produced the export. Use a fresh subagent with no inherited context, or a person who does not work with the uploader. If the only available reader already knows the uploader, say the cold read was not run rather than running it and reporting a pass.
What to ask. Hand over the sample and this question, verbatim:
This is a transcript from someone's AI coding assistant. From this text alone, name the person, name the company they work for, and name three of their colleagues. If you cannot name any of them, say what you would need in order to. Do not guess: if a name is not in the text, say so.
What to do with the answer.
| Answer | What it means | What to do |
|---|---|---|
| A real name, and it is correct | A leak. The reader found a name the table never had. | Get the exact substring they read it from. Add it to the entity list, or to known-values.json if it is the uploader's own. Re-export. Do not send the archive. |
| A real name, and it is wrong | A guess off a pseudonym or a common word. Not a finding. | Nothing. Do not add it to any list: a wrong name in the entity table substitutes text that was never an identity. |
| "I cannot name them", plus what they would need | The intended result. | Record what they said they would need. If the missing piece is in the archive and they simply did not connect it, that is next run's problem. |
| A role, not a name ("the person who runs payroll") | Re-identification by role, which substitution does not address and the README already discloses. | Not a leak. Report it to the person as a limit, in the handover. |
| The employer, from product or repo vocabulary | A name the entity list never had. The repo name is seeded from the git remote; the written-out company name is not seeded by anything. | Add the exact spellings they read it from to the entity list and re-export. |
Only the person running the export can score this, because only they know the real names. Score it yourself against what you know; do not paste the real names into the reader's prompt to check. A prompt containing the answer cannot ask the question.
Report the cold read in the handover either way: which entries were sampled, who read them, and what came back. "Not run" is an acceptable line. "Passed" with no reader named is not.
8. Hand it over
The archive is a file. The tool does not upload it and has no receiver.
Tell the person: the file, its size, the session count, and what was held back. Then let them send it. A privacy decision is theirs to execute, not yours.
export-map.txt is written at the top level of --out, outside send/, and
maps each original session id to its entry inside the archive. It is local, it is
not in the archive, and it must not be sent: it is the only thing on the machine
that says which entry is which session. The archive itself is send/deident- export-<date>.zip, and it is the only file in that directory.
Things that are not bugs
- It refuses a lot. Every refusal names a remedy. That is the design: a check nobody can bypass is worth more than a check that degrades quietly.
- The archive contains no code, only line counts, and no tool output at all, only shape: which tool, whether it failed, how many bytes came back. That is deliberate; the export exists to show how a person works, not to ship their repository. A consumer whose scoring reads result CONTENT gets less than it did, and should be told so rather than left to find out.
- Sessions written since the last
scanare held back, counted asnever reviewed. Re-runscanto decide them. Opt-in has to mean opt-in. - A session is offered again when a setting changes what it retains, not
only when the person types into it. Moving a tier, or adding a directory back
with
--include-denied, changes the prose a reader would see, so it is not covered by the last read.
What it does not protect against
Say this plainly when handing over, because the tool prints it and a person reading a summary will not see it:
- Names the reader did not find. The residue scan can only look for what it was given.
- Names the reader was never shown. The candidates file is prose, so a name that appears only in a tool call's PARAMETERS (the path read, the command run, the brief given to a subagent) or only in a code block never reaches it: it cannot be declared, and the residue scan cannot look for what was never declared. Tool RESULTS used to be the bulk of this gap and no longer are: they do not ship.
- Identity-document numbers labelled in a language other than English or Chinese, or written with no label beside them. The sweep is label-anchored on purpose, for precision.
- Spellings whose case change alters their length (Turkish dotted capital I, German sharp s) match only in the exact casing given. The matcher takes its span from the spelling's length, so folding them would consume the wrong span and reversal would restore the wrong text. A deliberate miss, not a corruption.
- Facts that are not names: a shareholding, a rate, a balance.
- Re-identification by role. Substitution replaces the name; "the person who runs finance and payroll" still resolves to one human at a small company.
- Device fingerprint: model mix, CLI version sequence, local ports.