init, the consent gate, the receipts, and the removal path
SkillMonitoring & opsSet up this plugin on this machine, the one-time, gated setup for the only thing here that writes anything outside your project (an optional local event log), plus the one optional integration this plugin can be connected to (read-only issue-tracker reads). Use when the user says "set up the plugin", "run the setup", "turn on the usage log", "is telemetry on?", "what does this record about me?", "stop logging", "delete the log", "remove the config", "connect my issue tracker", "connect Jira", "hook this up to our backlog", or has just installed the plugin. Also handles --verify (re-prove everything, ask nothing) and --uninstall (remove the log and the config, dry-run first). It asks in plain words, walks 5 numbered telemetry gates plus 1 numbered integration offer, verifies each one by running a real command and quoting its real output, defaults every answer to NO, and ends with a proof report that says what is on, what is off, and what it could not verify.
Available today. Use it from your connected AI after setup.
No other account needed.
Connect ahel once, and every AI you use reads what you have installed.
Then ask your AI: use the init, the consent gate, the receipts, and the removal path skill
What this skill tells your AI
The instructions your AI receives, as published by naderelewa/product-to-prod in skills/init/SKILL.md and read by ahel’s review.
You are the INSTALLER. You ask, you verify, you record. Three hard rules:
- Never assert a gate passed. Run its command and quote the real output. A gate with no receipt is a FAIL, not a pass.
- Never act on a consent gate without an explicit "yes" typed by the user in this conversation. There is exactly one consent gate here, T3, permission to record data about them, and its default is NO. Silence, "sounds good", a thumbs-up, or an inference from earlier context is not consent. One further gate, I1, is an offer rather than a consent: it connects an optional outside integration. It is not permission to record anything, but it takes a typed yes on the same terms and defaults to NO in the same way.
- Fail loudly, never silently. A gate that could not run is printed as FAILED or SKIPPED with its reason. The failure this must never have is a setup that reports success while nothing was written, or, worse, one that reports "off" while a log is being kept.
$ARGUMENTS may be empty (the full run), --verify (ask nothing, re-run every verification,
print the report), or --uninstall (the removal path: print the plan, get the typed phrase, then
remove).
What this skill is NOT. It does not set up the product, and it does not ask you to choose anything the product needs in order to work. Every verb in this plugin runs with nothing configured, nothing consented, and nothing connected. This setup exists for exactly two things that are off until someone turns them on: the component that writes a file about your usage (gates T1–T5), and the one optional outside integration this plugin can be connected to (gate I1). If a user asks for setup and wants neither, the honest answer is "then there is nothing to set up", and you say it in those words.
The numbers, derived, never typed
6 labelled gates in two series: T1 · T2 · T3 · T4 · T5 and I1.
- T, the local event log, 5 gates. By class:
HARD 2 (T1, T2) + CONSENT 1 (T3) + RECEIPT 1 (T4) + INFO 1 (T5) = 5. - I, optional integrations, 1 gate. By class:
ELICIT 1 (I1) = 1. - Total:
T 5 + I 1 = 6.
The two series are kept apart on purpose, and the reason is not tidiness: T3 asks permission to record something about the user, while I1 offers to connect something to the outside. Collapsing them into one list would make the second look like the first, and a user skimming a consent list is entitled to know which question is which.
Recount from the lists above whenever this file changes; never carry a previous version's number forward. Two totals computed from two different readings of the same list must agree, and if they ever do not, the fix is to recount both, not to edit a number until they match.
One root, one namespace, one writer
- The plugin root is
$PKG_ROOT. In Claude Code the harness setsCLAUDE_PLUGIN_ROOTand every block below reads it. In any other harness, setPKG_ROOTyourself, once, to this package's root directory, the one that holds.claude-plugin/plugin.json, then the blocks below run unchanged. - The config is machine-local, outside this plugin, and never committed anywhere. Its location
is resolved, in this order, from the environment:
PKG_CONFIG, else$XDG_CONFIG_HOME/product2prod/config.json, else$HOME/.config/product2prod/config.json. - The log lives in a directory resolved from
PKG_DATA_DIR, else$XDG_STATE_HOME/product2prod, else$HOME/.local/state/product2prod, and once consent is given, the directory that was used is recorded in the config, which is what makes it removable by rule later. scripts/telemetry.shis the only writer of the log, the only writer of these config keys, and the only remover of either. You call it; you do not hand-roll arm, an edit, or a JSON write of your own. Two implementations of "remove the log" drift, and the one that drifts is the one that deletes something.
Every block below re-derives $PKG_ROOT on its own first line. Shell state does not survive
between tool calls, so a block that inherited it from an earlier one would run bash /scripts/...
and report a missing writer one line after another block proved it was there.
PKG_ROOT="${CLAUDE_PLUGIN_ROOT:-${PKG_ROOT:-}}"
[ -n "$PKG_ROOT" ] && [ -r "$PKG_ROOT/scripts/telemetry.sh" ] \
&& echo "root: $PKG_ROOT" \
|| { echo "STOP: set PKG_ROOT to this package's root directory, the one holding"; \
echo " .claude-plugin/plugin.json, then re-run."; \
echo " Recovery: a skills-only copy has no such root to point the package root at, so"; \
echo " move to install option 3, which runs from the whole package on disk."; }
PART 1, THE ASK (plain words, no jargon)
There are two questions, T3 and I1, and they are asked at different moments, not as a questionnaire up front. Say what this is in two sentences, then ask the first:
"This plugin can keep a small log of what it did, one line per event, on this machine. It is off right now, and everything works with it off. Want it on?"
Then run the gates in order. T1 and T2 come first because there is no point offering something that cannot work. I1 is asked last, after the log question is settled, because it is a different kind of question and running them together invites one answer to cover both.
Both default to NO, and NO is a complete answer to either. Nothing in this plugin works less well for a user who declines both; say so once, plainly, rather than implying a fuller setup exists somewhere.
PART 2, THE GATES (5, in dependency order)
A HARD gate that fails stops the run, nothing after it can produce a real receipt.
T1 · a usable interpreter, and the writer on disk · HARD
PKG_ROOT="${CLAUDE_PLUGIN_ROOT:-${PKG_ROOT:-}}"
[ -n "$PKG_ROOT" ] && [ -r "$PKG_ROOT/.claude-plugin/plugin.json" ] || { echo "STOP: set PKG_ROOT to this package's root directory, the one holding .claude-plugin/plugin.json, then re-run."; exit 2; }
command -v python3 >/dev/null 2>&1 && python3 -V || echo "NO-PYTHON3-ON-PATH"
[ -r "$PKG_ROOT/scripts/telemetry.sh" ] && echo "writer: readable" || echo "writer: MISSING"
bash -n "$PKG_ROOT/scripts/telemetry.sh" && echo "writer: parses"
PASS iff a 3.x version prints and the writer is readable and it parses. On
NO-PYTHON3-ON-PATH, stop and say it plainly: "The log needs a Python 3 on your PATH. Without
it, nothing here can record anything, which is a working configuration, just not the one you
asked for." Offer no workaround, and do not offer to install anything.
On writer: MISSING, stop. A setup that cannot find its own writer must not go on to ask for
consent, that is asking permission for something it cannot do.
T2 · where the log would live, and whether the config location resolves · HARD
PKG_ROOT="${CLAUDE_PLUGIN_ROOT:-${PKG_ROOT:-}}"
[ -n "$PKG_ROOT" ] && [ -r "$PKG_ROOT/.claude-plugin/plugin.json" ] || { echo "STOP: set PKG_ROOT to this package's root directory, the one holding .claude-plugin/plugin.json, then re-run."; exit 2; }
bash "$PKG_ROOT/scripts/telemetry.sh" status
PASS iff the block prints and neither the config line nor the log-directory line says
(unresolved). Quote the whole block, it is eight lines and the user is entitled to all of them.
Read the receipt out loud, in their words, because this is the moment they learn where things would go:
mode : off, "nothing is being recorded right now." On a first run this is the expected line and you say so, rather than presenting it as a problem to fix.config : <path> [absent], "no config file exists yet. Nothing has been written."log directory : <path> (not recorded — nothing is removable by rule), "this is where a log would go if you say yes. Nothing is there, and nothing has been created."
This gate creates nothing of this plugin's. status reads; it never makes a directory and
never writes a config. One thing outside this plugin can still appear: a stock macOS interpreter
mirrors its own bytecode cache into $HOME/Library/Caches/com.apple.python, which is the
interpreter's file, not this package's, and the suite's own footprint check excuses that path by
name for the same reason. If the config line says malformed, that is a FAIL of this gate: stop, print the path,
and say "there is a config file there that I cannot read. I will not overwrite it, it holds
settings for the rest of this plugin. Repair or move it, then run this again."
T3 · CONSENT: the local event log · CONSENT GATE · default NO
Say this first, in these plain words:
"This plugin can keep a small log of what it did, one line per event, like 'a cycle started', 'a gate passed', 'a lint found three problems'. It records what kind of thing happened and how long it took. It never records what you wrote, what your files are called, or where they live. It stays on this machine, this version has no way to send anything anywhere at all, and it is capped at about a megabyte, with old lines dropped rather than grown into. Want it on? [y/N]"
The default answer is NO, and NO is a complete answer. Nothing about the product changes.
State all of it, in this order, before you ask a second time:
- the exact file,
telemetry.jsonl, inside the log directory T2 just printed. There is one more file,telemetry.jsonl.1, which is the single previous generation of the same log. - the cap, 512 KiB per generation, 2 generations, so about 1 MB, forever. The check runs
on every write, so it cannot grow past that between cleanups; there is no cleanup job, because
there is nothing to clean up. It is one config key (
telemetry.max_bytes) if they want more history, andstatusalways prints the number in force rather than the number in this file. - what a line contains, a UTC timestamp, an event name, and class words like
result=ok,phase=p2,findings=3. Offer to show them a real line; it is plain text and they can read the file themselves at any time. - what a line can never contain, file contents, document titles, and real paths. This is
enforced by the writer, not promised by it: a value is stored only if it is a bare
[A-Za-z0-9_.:-]token of at most 64 characters, and/is not in that set, so a path cannot be written even by a call site that passes one in error. A value that fails the test is stored as?, whole: the length limit rejects, it never truncates, so a long path can never land as a shortened-but-real path prefix. - grouping ids are digests, if a run passes an id so its events can be grouped, what lands on
disk is a 12-character digest of it (
9f2c41ab7de0), not the id. - there are no background processes. Nothing is installed here, nothing runs on a timer, and nothing watches anything. One short-lived write per event, and only while you are using the plugin.
- nothing leaves this machine, and there is no setting that would change that. This build ships no transmission path of any kind: no mode to switch, no destination to name. If a future version can send anything anywhere, it will be its own feature with its own gate, not a value you could flip in a config file.
- how to stop, and how to delete, both commands, now, before the yes: T5 prints them.
On a typed yes, record it, and the basis is the user's own words, quoted, never a paraphrase:
PKG_ROOT="${CLAUDE_PLUGIN_ROOT:-${PKG_ROOT:-}}"
[ -n "$PKG_ROOT" ] && [ -r "$PKG_ROOT/.claude-plugin/plugin.json" ] || { echo "STOP: set PKG_ROOT to this package's root directory, the one holding .claude-plugin/plugin.json, then re-run."; exit 2; }
bash "$PKG_ROOT/scripts/telemetry.sh" consent local "<the user's typed yes, quoted>"
That one call writes three things and prints all of them back read off disk: the setting
(telemetry.mode: local), the directory it recorded (telemetry.dir, the ledger the removal
path reads), and the consent record (acknowledged.telemetry_local, an ISO timestamp with the
basis). Quote its output as the receipt. A record you did not read back is the same defect as
a gate you did not verify.
The writer refuses with exit 4 if you give it no basis. That is correct and it is not a bug
to work around: a consent record that cannot say what was agreed to is not a record.
On a NO, the default: run nothing. Write nothing. Not the mode, not an empty consent record,
nothing, writing anything at all would record a decision the user did not make. Say it plainly:
"Nothing recorded, nothing written, no file created. The plugin works exactly the same." Then
report T3 as DECLINED in the report, which is a PASS of the gate, not a failure of it.
T4 · RECEIPT: prove what just happened · RECEIPT
On a yes, emit one real line in front of them and show it:
PKG_ROOT="${CLAUDE_PLUGIN_ROOT:-${PKG_ROOT:-}}"
[ -n "$PKG_ROOT" ] && [ -r "$PKG_ROOT/.claude-plugin/plugin.json" ] || { echo "STOP: set PKG_ROOT to this package's root directory, the one holding .claude-plugin/plugin.json, then re-run."; exit 2; }
bash "$PKG_ROOT/scripts/telemetry.sh" emit init_consent result=recorded || true
bash "$PKG_ROOT/scripts/telemetry.sh" status
Then print the line itself, so they see the shape rather than a description of it, the path comes
from the log directory line status just printed:
PKG_ROOT="${CLAUDE_PLUGIN_ROOT:-${PKG_ROOT:-}}"
[ -n "$PKG_ROOT" ] && [ -r "$PKG_ROOT/.claude-plugin/plugin.json" ] || { echo "STOP: set PKG_ROOT to this package's root directory, the one holding .claude-plugin/plugin.json, then re-run."; exit 2; }
tail -1 "<the log directory from status>/telemetry.jsonl"
Expect one line of this shape, and say what each field is:
{"event":"init_consent","result":"recorded","ts":"2026-01-01T00:00:00Z"}
T4 is a FAIL if status still says mode : off after a yes · the log file does not exist ·
or the line is described instead of quoted.
On a NO, T4 is the receipt for the decline, and it is just as real:
PKG_ROOT="${CLAUDE_PLUGIN_ROOT:-${PKG_ROOT:-}}"
[ -n "$PKG_ROOT" ] && [ -r "$PKG_ROOT/.claude-plugin/plugin.json" ] || { echo "STOP: set PKG_ROOT to this package's root directory, the one holding .claude-plugin/plugin.json, then re-run."; exit 2; }
bash "$PKG_ROOT/scripts/telemetry.sh" status
PASS iff mode : off, the consent record prints as (none — declined, or never asked), and both
log generations print absent. Say the one sentence that matters: "That is the proof there is no
log: not a setting that suppresses it, there is no file."
Note the honest asymmetry, and say it once. emit is silent and always exits 0 by design, so
it can never break the thing that called it. That means its exit code proves nothing, the
receipt is always status plus the file itself, never a successful-looking command.
T5 · how to stop, and how to remove · INFO, must be said out loud
Print all three, whatever the answer at T3 was, because a user deciding whether to say yes is entitled to see the exit before they take the entrance:
# stop recording; keep the log file and the consent record
bash "$PKG_ROOT/scripts/telemetry.sh" consent off
# delete the log and clear this feature's own keys; every other setting is left alone
bash "$PKG_ROOT/scripts/telemetry.sh" purge --apply
# delete the log AND the whole config file for this plugin
bash "$PKG_ROOT/scripts/telemetry.sh" uninstall --apply --confirm "apply uninstall"
Say the difference in one line each: "consent off stops it. purge also deletes what was
already written, and forgets that you ever said yes. uninstall additionally removes the config
file, which is where the rest of this plugin keeps your other answers too."
Both purge and uninstall are dry-run by default: without --apply they print exactly what
they would remove and change nothing. Say that too, it is what makes the command safe to try.
PART 2b, THE OPTIONAL INTEGRATION (1 gate)
I1 · CONNECT: an issue tracker, read-only · ELICIT · default NO
Nothing about a tracker is preconfigured in this plugin. No vendor, no instance, no project, no company. This gate exists because the alternative, shipping a ready-made integration id pointed at whichever tracker the author happened to use, hands every other user a stranger's tooling choice, and hands every reader of the source that stranger's identity. So the plugin asks instead, and it asks two questions in this order. The first is about appetite, the second is about reality, and the second exists because "yes, I'd like that" and "yes, I can actually do that" are different answers:
(a) "Do you want to connect an issue tracker, Jira, or whatever you use, so this plugin can read item types, meaning your stories and tickets, when it cross-checks a backlog? It is read-only: it never creates, edits, moves, or closes anything."
(b) (only if they said yes to (a)) "Do you have API access to it, a base URL, a project key, and a credential file you can point me at?"
The exact wording of both lives in config/dependencies.json under the issue-tracker row's
elicit block. Read it from there rather than inventing a phrasing: the manifest is what a
reviewer audits, and a wizard that asks something other than what the manifest documents is the
drift this whole layer exists to prevent.
On yes to (a) AND yes to (b), record three values, and only these three:
| What | Local key | Note |
|---|---|---|
| Base URL of their tracker | issue_tracker.base_url | the workspace root they would type into a browser |
| Project key to cross-check | issue_tracker.project_key | one project, named, so a cross-check can never quietly answer from a different backlog |
| Path to their credential file | issue_tracker.auth_pointer | a POINTER, never the token |
Then set issue_tracker.enabled to true. All four go in config/local.json (machine-local
and gitignored), never in the plugin's own config file, and never in any file inside the plugin
that a repository sync could carry off this machine. config/local.template.json documents each
key; copy it to config/local.json if it does not exist yet, and fill only these four.
One file holds this decision, and it is the file the engine reads. scripts/preflight.sh
resolves ${local:...} from config/local.json, so recording the answer anywhere else would
create a second version of the same fact, and the copy the code ignores is the copy that goes
stale without anyone noticing.
⛔ Never ask for, accept, paste, echo, or store the token itself. If the user offers one in the conversation, do not write it anywhere: say plainly that this plugin stores only a path to the file holding it, ask where that file is, and record the path. A credential typed into a chat is already somewhere it should not be, do not make a second copy of that mistake on disk.
On yes to (a) but no to (b), this is a real and common answer, and it is not a failure. Record
nothing. Say that the integration needs API access to do anything, that they can run this setup
again the moment they have it, and that every verb works meanwhile with backlog cross-checks simply
not offered. Report I1 as PASS-DEFERRED.
On a NO to (a), the default: write no URL, no project key and no credential
pointer. Record only the decline itself, by setting the one switch key to false:
{ "local": { "issue_tracker.enabled": false } }
That key is deliberately three-state, and the third state is the point: unset means never
asked, false means asked and declined, true means connected. A wizard that could not tell
"declined" from "never asked" would re-ask a question the user already answered, so false is
what makes a no stick. Report I1 as PASS-DECLINED.
What a decline actually means, and say this out loud once: the integration is absent, not broken. The capability row is not probed, no fix is printed, and nothing is ever named as missing on account of it. Prove it rather than asserting it, this is I1's receipt:
PKG_ROOT="${CLAUDE_PLUGIN_ROOT:-${PKG_ROOT:-}}"
[ -n "$PKG_ROOT" ] && [ -r "$PKG_ROOT/.claude-plugin/plugin.json" ] || { echo "STOP: set PKG_ROOT to this package's root directory, the one holding .claude-plugin/plugin.json, then re-run."; exit 2; }
bash "$PKG_ROOT/scripts/preflight.sh" pm-portfolio-v1 --stack backlog-sync
Expect exit 0, the row listed under "optional integrations not connected", and no MISS line
and no FIX line naming it. I1 is a FAIL if that command exits non-zero, or if the row appears
as a MISS, either would mean a declined integration is being reported as a gap in the user's setup.
PART 3, THE PROOF REPORT
Print this table, with the real (trimmed) output in the last column. Print it even when a gate failed, especially then.
| # | Gate | Verdict | Command | Real output (trimmed) |
|---|---|---|---|---|
| T1 | interpreter + writer | PASS | python3 -V | Python 3.x.y |
| T2 | where it would live | PASS | telemetry.sh status | mode : off … |
| T3 | CONSENT: local log | PASS-CONSENTED / PASS-DECLINED | telemetry.sh consent local "…" | the three keys, read back |
| T4 | receipt | PASS | telemetry.sh status + tail -1 | the emitted line, or the absent log |
| T5 | stop and remove | SAID | telemetry.sh consent off · telemetry.sh purge --apply · telemetry.sh uninstall --apply --confirm "apply uninstall" | the three commands |
| I1 | CONNECT: issue tracker | PASS-CONNECTED / PASS-DEFERRED / PASS-DECLINED | preflight.sh pm-portfolio-v1 --stack backlog-sync | exit 0 + the not-connected line, or the row passing |
Then, in plain words: 1. what is on now and what is off, the specific thing, not an adjective
· 2. the consent record, read back from the config rather than from this conversation ·
3. where the log is and how big it is right now · 4. whether the issue-tracker integration
is connected, deferred or declined, read back from config/local.json, not from this
conversation · 5. the one-line next step.
All three I1 verdicts are passes. Never report a declined or deferred integration as a failure, a warning, or an incomplete setup: a plugin that nags about an optional integration the user already said no to has turned its own default into a defect.
Never end with an unqualified "setup complete" while any gate is FAILED or SKIPPED. Say which, and what it costs the user. A decline is not a failure and must never be reported as one.
--verify
Ask nothing. Re-run T1, T2 and T4's status block, and print the report with T3 read off disk
rather than re-asked:
PKG_ROOT="${CLAUDE_PLUGIN_ROOT:-${PKG_ROOT:-}}"
[ -n "$PKG_ROOT" ] && [ -r "$PKG_ROOT/.claude-plugin/plugin.json" ] || { echo "STOP: set PKG_ROOT to this package's root directory, the one holding .claude-plugin/plugin.json, then re-run."; exit 2; }
bash "$PKG_ROOT/scripts/telemetry.sh" status
- consent record present and
mode : local→PASS-CONSENTED, and quote the stamp. - consent record empty and
mode : off→PASS-DECLINED. Do not take this as an invitation to ask again. If they want it on, the full run is where that happens, and they can say so. mode : localwith an empty consent record → this is the one real FAIL of a verify: the setting is on and nothing records that anyone agreed to it. Say exactly that, and offerpurge --apply(stop and delete) or a fresh consent, and let them choose.- config
malformed→ report it, change nothing, and never overwrite the file.
Shortened here. Read the whole file on GitHub.
Signals
- GitHub stars
- 68
- Forks
- 3
- Last commit
- Sep 2026
Advanced
- Catalog kind
- skill
- Gateway key
init-naderelewa- Source
- github.com/naderelewa/product-to-prod