Skip to content

[SDK-756] Two new skills that take Android push from nothing to a proven delivery - #24

Merged
franco-zalamena-iterable merged 46 commits into
mainfrom
feat/one-plugin-provision-verify
Sep 25, 2026
Merged

franco-zalamena-iterable merged 46 commits into
mainfrom
feat/one-plugin-provision-verify

Conversation

@franco-zalamena-iterable

@franco-zalamena-iterable franco-zalamena-iterable commented Sep 25, 2026 •

Copy link
Copy Markdown
Contributor

Two new skills and the tool behind them, so that "set up Iterable push" ends with a
push arriving on a real device instead of with a green build and a list of things the
developer still has to go and do.

What ships

iterable-provision — produces and proves the prerequisites: a Firebase Android app
and its real google-services.json, a service account scoped to sending push plus its
key, and the four Iterable dashboard steps. It is what iterable-android /
iterable-react-native now route to when Preflight reaches an input nobody has, instead
of stopping.

iterable-verify — sends a real push and reads the device's own notification service
to confirm it arrived. Read-only: it never provisions, never edits the app, and never
re-sends to manufacture a pass.

bin/ — the program both skills drive. Eighteen gates, G0–G17, and one rule:
the actor never grades its own work. bin/provision and bin/proof-push do things;
bin/gates judges them and can do nothing. Two gates cannot be faked — G9 mints a token
from the service-account key and makes FCM accept it, G16 finds the push in
dumpsys notification by the marker the send wrote.

Three Iterable steps have no public API — checked path by path against the published
spec, not assumed — so a human clicks those three and the tool proves the result. G11 and
G12 print ~ unverifiable rather than a tick, and exit 40 exists so that "nothing is
broken and nothing is proven" is sayable. A gate that reads a local file to decide a
remote step happened is the false pass this whole thing exists to prevent.

One program, three front ends. bin/wizard for a human, bin/agent emitting JSON
for a model, and the chat itself — the agent renders the wizard's own screens in the
conversation rather than handing over a command to paste. INTENDED-FLOW.md is the
contract for that path, because the same handover failure had been fixed three times and
come back three times; it now changes before the code does.

Build-team tooling (tools/, Makefile) is deliberately outside agents/, so a
client does not pay always-on token cost for roles only this repo needs.

The README now describes this plugin rather than the two skills it started as. A reader
had no way to learn that two of the four skills existed, that anything needed gcloud, or
that a device was required at all. It gains a skill table with the funnel order, a
prerequisites table with install routes — G0 tells a developer to install gcloud and
adb and does not say how, which is the one blocked state in this tool that never named its
remedy — and a security section a reviewer can read on its own. Two internal design
documents under docs/plans/ are dropped: on a public repo they published a debugging log
and stale paths, and nothing in bin/ read them.

Evidence

  • make test — 19 suites, 435 checks, 0 failed. 2 skip and say why; 1 needs the network.
  • A green end-to-end run against a real project and a real emulator: 15 of 15 verifiable
    gates, exit 0, G16 matching the send's own marker two seconds later.
  • pnpm check:all green.

Both are now reachable from CI. They were not: nineteen suites existed and nothing ran
them, so validate.yml gets a macos-latest job — macOS because that is where the tool
runs and because its /bin/bash is 3.2, which fails on constructs ubuntu would accept.

The five release blockers from the 2026-09-22 audit are closed

All five, each pinned by a test that fails without its fix:

  • .env was sourced, so a file in the workspace could redefine approved() and forge
    consent, set APPROVED=1, or clobber a key exported in the shell. It is now parsed.
  • Credentials were passed in curl -H, readable via ps. They now go through a curl
    config file on stdin.
  • save_env left a 0644 window on a temp file holding every other key.
  • A failed google-services.json fetch truncated the good file, because the redirect was
    on the target.
  • teardown deleted the local key even when deleting the service account failed —
    leaving a live credential on disk with nothing pointing at it.

Four of the five were invisible to any end-state test: the code ended correctly and hid a
window in the middle.

Security posture, unchanged and load-bearing

The tool never handles the developer's Google or Iterable password — they authenticate in
their own browser or gcloud and the agent attaches to that session. It never defeats a
CAPTCHA or accepts ToS on their behalf. Keys are written mode 0600 into a gitignored
workspace, never echoed, never in argv, with deletion offered after upload. web-operator
is the only agent with browser access and is origin-confined. gate-auditor is read-only
and no agent audits its own gate.

How to review 77 files

Twelve are logcat/dumpsys fixtures and 24 are the tests that read them, so the surface is
smaller than the count. Suggested order:

  1. INTENDED-FLOW.md — the contract. If you disagree with anything, disagree here
    first; the code is downstream of it.
  2. bin/gates — the judge. It can do nothing, and that is the point.
  3. bin/config.sh — the spine, and where four of the five blockers lived. It is one
    long file on purpose: see the .env note, whose blast radius was 64 functions.
  4. The two new SKILL.md and the routing edits in the two existing ones.
  5. bin/questions.sh + tests/agent-questions.sh — the screens, and the proof that
    the chat and the terminal show the same ones.

I tried to split this into module PRs and it does not divide cleanly. Eight commits change
the tool and a skill in one breath — fca2a61 is a single idea across 25 files — so a
directory-wise split would produce commits that are not true on their own: a tool emitting
screens nobody renders, or a skill describing a program that isn't there. tests/plugin-manifests.sh
asserts on both new skills, so a tool-only PR is also red. Splitting by commit range
divides more cleanly but lands the five audit blockers on main and fixes them in a second
PR, which is worse. The one piece that was genuinely free — a pnpm pin for the bump
workflow, broken on main today — is split out separately.

What to push on in review

  • The RN definition-of-done still has no device-side proof step
    (iterable-react-native/SKILL.md) — iterable-verify covers Android first.
  • teardown leaves .env and the approval marker on disk on purpose (they are the
    developer's own credentials), so a "cold" re-run is not fully cold.
  • iterable-android/SKILL.md is 663 lines and wants cutting toward 500.

Manifest version goes to 26.9.12-beta, and this PR also adds bin/**, agents/** and
the two new skills to the bump workflow's path filter. They were missing, which is why a
season of fixes to this tool landed on main and reached nobody: the host had nothing new
to install, so it kept running the cached copy.

🤖 Generated with Claude Code

A tool that takes an Android developer from a Firebase project to a push
notification arriving on a device, and proves every step by reading the real
subject rather than the local config that was supposed to change it.

Eighteen gates, G0-G17. The actor never grades its own work: bin/provision and
bin/proof-push do things, bin/gates judges them and can do nothing. Two gates
cannot be faked — G9 mints a token from the service-account key and makes FCM
accept it, G16 finds the push on the device.

The Google half is deterministic. Three Iterable steps have no public API at
all — verified against all 131 paths in the published spec, not assumed — so a
human clicks those three and the tool proves the result; G11 and G12 print
"~ unverifiable" rather than a tick, because a gate that reads a local file to
decide a remote step happened is the false pass this tool exists to prevent.

The device half reads the device. G13 asks dumpsys package whether the installed
APK resolves IterableFirebaseMessagingService, which catches "integrated the SDK,
forgot to reinstall"; G14 reads logcat and treats silence as pending forever,
because the buffer rotates and absence proves nothing; G16 reads dumpsys
notification for the marker bin/proof-push wrote into the message it sent.

Exit 40 exists so that "nothing is broken and nothing is proven" is sayable. A
live run reported a defect in someone's Iterable project when the truth was that
nobody had launched the app yet.

Ran green end to end on 2026-09-21: 15 of 15 verifiable gates, exit 0, against a
real project and a real emulator, G16 matching the send's own marker 2s later.
That is one run, on one app, by the person who wrote the tool.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A first real trial found three things, none in the half just tested.

"2 devices attached — export ANDROID_SERIAL=emulator-5554" is correct and
useless: a port number is not something anyone recognises as their own device,
and being told to go export a variable is the "run something and come back" the
wizard exists to avoid. So the wizard picks. It lists AVD names, offers AVDs that
aren't running and boots the one you choose, and asks even when exactly one
device is attached — a phone left plugged in to charge is still the wrong thing
to prove a push on. bin/gates still never prompts; it names every candidate and
exits, because it has to stay safe to drive from a loop. The choice is remembered
as the AVD name, never the serial: those are handed out in boot order.

bin/iterable-keys asked for "any email address". It is the join key between the
app and Iterable — the SDK files the token under whatever the app passes to
setEmail(), and the proof push goes to ITBL_EMAIL. Give it a different address
and G15 says "no user yet", which reads exactly like a broken Iterable project
and is not one. The prompt now says what the value has to be; G15 names the other
possibility; and the advice both front ends print now comes from one function, so
they cannot drift on it.

Two verdicts lied in opposite directions. The wizard provisioned nothing and
printed "Done — and verified, not assumed" with no push proven; and once G16 went
green while something else was pending, the ladder still said "No push has been
proven". Both now read G16 rather than the exit code, which only ever says that
something is pending and never what.

Also: brief() truncated at 100 characters with cut, ending a remedy mid-word at
"a different addres" — it now cuts on a word boundary and says it cut. A defined
env var beats the cache even when defined empty, so TARGET_DEVICE= forgets a
remembered device. WS is overridable, because a test that reads the developer's
live resolved.env is not offline — which is how tests/device-pick.sh can pin all
eleven ways a device gets resolved against a stubbed adb.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…pens

boot_avd could only be reached by a human picking a stopped AVD, so three
attempts to drive it through a pty failed and it shipped as inspection-only.
Moved to config.sh — the same move brief() needed — and it is now three offline
cases: an AVD that comes up, one that attaches to adb but never sets
sys.boot_completed, and one that never attaches. The middle case is the point:
adb answers long before the OS is up, and it is the only thing proving the wait
is on the property rather than on the port.

Next to it, the wizard said the device gates would wait for an AVD that failed
to boot. They never wait — gates has to stay safe to drive from a loop. It now
says the choice is remembered and that G13 onwards will report it as not
running.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ITBL_EMAIL was a free-text prompt for a value the app decides in code. Typed
wrong, G15 reports "no user yet", which is indistinguishable from a broken
Iterable project — and it was typed wrong three times across runs. The SDK logs
its own registerDeviceToken body, so app_identity() reads the address from there
and iterable-keys offers it as the default. Nothing registered yet means no
default: a guessed identity is worse than a question.

Second finding, from asking for visual confirmation and getting none: Android
groups a second notification from the same app with the first and marks the
children SILENT, so the second proof push of a session arrives, satisfies G16,
and shows nothing on screen. proof-push now warns when an earlier proof is still
in the shade. G16 also states the age of the arrival past ten minutes, since
re-running the ladder does not re-send and that green can be hours old.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Reported as "it says arrived but it didn't really, maybe it's reading an old
value". The gate does not fabricate — with an empty shade it reports pending —
and the push had in fact arrived. It arrived SILENT, because Android bundles a
second notification from the same app with the first, so nothing appeared on
screen. proof-push already warned about that; the complaint came from reading
gates, which did not. A caveat only the actor prints is invisible to anyone who
runs the verifier, which is the normal way to use this.

G16 now reads flags off the record and says "SILENT, no banner", and states the
age of every arrival rather than only ones over ten minutes old. That threshold
existed to fit brief()'s 100 characters — a formatting reason for withholding the
one fact that reconciles a green gate with a developer who saw nothing. Quoting
the notification's text without its title buys the characters back.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The wizard stays exactly as it is — a human at a terminal gets menus, and
`bin/onboard` still dispatches on whether stdin is a TTY. What was missing is the
third front end: a model can run the ladder today, but only by reading a table
meant for a person, and the one thing it needs most is not in that table at all.

`bin/agent` prints one JSON object and puts the human ladder on stderr, so the
account of what happened survives. The load-bearing field is `next`: one owner,
one kind, one command. It is derived from `state.tsv`, never from the exit code —
rc 40 says something is pending and never which thing, rc 10 says a human is
needed and never which step, and an agent that has to infer the difference will
confidently do the wrong thing where a human would have worked it out.

To make that possible, `state.tsv` grows from two columns to five: id, status,
owner, name, verdict. It had been the status only, which forced every front end
to re-derive the verdict text from terminal output — a second reader of the same
truth, and the two were already drifting.

Two things that had no scripted path now have one. `bin/discover --json` means the
agent turns real projects into real options instead of scraping a padded table.
And `bin/iterable-keys` without a terminal stops being a dead end: it prints the
same four dashboard steps and leaves `.env.template` at 0600 with three empty
names. The developer fills that in. Nobody pastes a key into a conversation, and
the keys are still proved by being spent, which is the only part that needs them.

The routing has more branches than any single live run reaches, and on the agent
path there is no human to notice a wrong turn, so `next_action` lives in
config.sh and `tests/agent-next-action.sh` runs all of it against fixture state
files. Writing it found three real defects, all of the same shape — code that
works until something upstream changes underneath it:

- `missing_tools` split a space-separated string, so any caller with a different
  IFS got one binary named "node gcloud adb java" and was told all four were
  missing. `bin/agent` is exactly such a caller.
- the five fields were tab-separated, and tab is IFS whitespace, so `read`
  collapsed a run of them: an action with no command arrived with its summary
  parsed as the command.
- the test's own stub directory was prefixed to PATH rather than replacing it, so
  removing the adb stub just exposed the developer's real adb and the
  missing-tool case passed by accident.

`.env.template` is also never overwritten once it holds a value. Re-running is the
normal way to re-read the steps, and an API key is shown once.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`iterable-provision` (G0-G12) and `iterable-verify` (G13-G17), the shape the
one-plugin plan calls for. They are here rather than in the skill repo so they
travel with the subtree move as one operation instead of leaving a half-merge.

The prose does one job: name the script, say how to read its output, and say what
to do with each `next.kind`. It does not restate a gate, because a check described
in Markdown is a check nobody has run — and the whole reason `bin/gates` exists is
that a model can be argued out of a rule that only lives in prose.

Two skills rather than one because they answer different sentences, and `verify`
is the one people re-run for weeks after setup. `verify` writes nothing and sends
nothing: `bin/proof-push` is a separate, announced step, because a checker that
triggers the thing it checks can only ever agree with itself.

Both begin with the walk-up. Hosts copy a plugin into a cache on install and set
no environment variable, so the marker is `bin/agent` specifically rather than a
bare `bin/` — a customer's own repo is very likely to have one of those.

The parts worth spelling out are the ones that cost days and that no gate can
catch: FCM type must be Data notifications, the test identity is a join key, and
a green G16 with nothing on screen is Android's SILENT bundling rather than a
gate that lied.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Records what was built today — `bin/agent`, `bin/discover --json`, the two
SKILL.md files — and two things the first draft got wrong, both found by asking
what the client actually experiences turn by turn rather than what we intend.

`bin/iterable-keys` refused to run without a tty, so the plan named no way for the
two Iterable API keys to reach the tool on the path we mean to ship. And nobody
owned copying `google-services.json` into `app/`; the platform skill does, because
it is the one that already writes into a customer's tree.

Drops the `bin/` → `scripts/` rename. It only ever served the walk-up, and the
walk-up wants an unambiguous marker, not a particular name — `bin/agent` is a
better one than either, since a customer's repo probably has a `bin/` and almost
certainly not a `bin/agent`.

Move 3 (`workspace/` → `.iterable/`) is now the blocking one: the agent path works
from our checkout and not yet from a plugin cache.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
git-subtree-dir: onboard
git-subtree-mainline: 54818ec
git-subtree-split: f0ee334
… project

The provisioning ladder arrived as a subtree, which put it at onboard/ — a
second repo's shape inside a plugin that ships one. Moved to where each part
belongs: bin/ next to the skills that name it, the two SKILL.md files beside
iterable-android so the manifest can list all four the same way, tests/ and
agents/ at the root, the tool's own README next to the scripts it documents.

The planned bin/ -> scripts/ rename is deliberately not here. It existed to
help the walk-up find the tool, and the walk-up wants an unambiguous marker
rather than a particular name: a customer's repo is very likely to contain a
bin/, and vanishingly unlikely to contain a bin/agent.

Three things the move forced, none of them cosmetic:

The workspace now resolves from the caller's cwd to <project>/.iterable,
never from the script location. A host copies a plugin into a cache that may
be read-only and is erased on the next update, so a customer's service-account
key and gate state have no business living there. Running an entry point from
inside the tool exits 30 rather than writing — a wrong cd is loud instead of
silently losing their state — and that guard immediately caught four offline
suites that had been reading the developer's live workspace.

next_action routes by which gate is open instead of by how it failed. Red and
pending stay different claims in the verdict, but they want the same next step,
and two tables meant the first genuinely cold run — nothing chosen, nothing
created — fell through the pending catch-all and told a client to go run an app
they had not built. Four checks that reported "nobody has supplied this yet" as
red now return 2, and the cold start is pinned offline.

Both new skills are registered, and the two SDK skills route their
google-services.json preflight into provisioning instead of stopping.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two emulators attached made G13 red with "pick one with bin/wizard, or export
ANDROID_SERIAL=…", and both halves of that are wrong on the agent path: there is
no wizard in the conversation, and an export does not survive to the next call.
TARGET_DEVICE is settable and durable, so say that instead — and say it before
the device list, because brief() truncates at 100 characters and two AVD names
were enough to cut the only actionable part off the end.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… time

A manual run surfaced three things, all of them about what the developer is
handed rather than what the tool knows.

The agent quoted our gate ids at them. "Waiting on G13" is our vocabulary, and
to somebody integrating an SDK for the first time it is a reference to a
document they do not have. The ids stay where they belong — the state file is
keyed on them — but `next.step` now carries the same step in words, blocked rows
name the step they are waiting on instead of its id, and both skills say plainly
that the ids and the status words are fields to branch on, not words to say.

The Iterable half arrived as a wall. Four dashboard steps, handed over at once,
read as a document instead of a sequence to follow — and the run stopped at
exactly that stretch with no .env to show for it. `bin/iterable-keys --step N`
prints one step, so an agent relaying it cannot dump four; each step names where
its values go, at the moment they exist, because a destination mentioned at the
end is one nobody reads until after the key has gone somewhere else. Running the
whole thing by hand is still there as the escape hatch, and no step ever asks
for a paste.

The identity suggestion was read from the wrong app. One logcat buffer is shared
by every app on the device and the SDK's lines are identical whoever wrote them,
so an unscoped read offered another app's address as the join key for an app that
was not even installed. Reads are now narrowed to the chosen package's uid; the
identity offer stays silent when it cannot attribute what it found, and the token
check labels an unscoped read as one rather than passing it off as evidence.

That scoping made the last defect visible: with no mobile key captured yet, "the
SDK ran and never registered" is not a fault, it is a step nobody has reached.
It reported as a defect on a live run. Now it is pending, and says why.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three things a developer should never have to discover after the fact.

Ask first. Provisioning runs against a cloud project somebody else owns, so the
list of changes now comes before the changes, and the yes is recorded against
that project rather than remembered by whoever asked. bin/provision refuses with
exit 10 and makes no call at all until it finds one; next_action routes to
approve_firebase ahead of every mutation. An agent that skips the question is
stopped by the program instead of by prose, and a yes for one project cannot be
spent on the next. APPROVED=1 is the same answer for a job with no human in it.

The plan in the banner is generated from the ladder, because there were three
hand-maintained copies of "what this would do" and a consent banner that
understates the work is worse than none.

Say what the output is. An AI agent wrote the code, and it can be wrong in ways
that compile and pass every check here — so the notice draws the line the
developer actually needs: the push proof is evidence, read back out of the
operating system; everything else is a draft to review like a pull request from
somebody new to their codebase. It is in the tool's voice, and the skills relay
it verbatim, because an agent vouching for its own output is worth nothing.

Make the blocker unmissable. One box, the owner first, styled by what it is: red
only for a real defect, yellow for work waiting on somebody. The label is keyed
on the action's kind, not the gate's status — "service account: not created yet"
is red and is also the tool's own next job, and a tool that calls its to-do list
BROKEN teaches somebody to stop believing the word.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The absolute path to bin/agent is already in `command`; repeating it inside
the summary meant the wrapped box broke it across two lines into something
nobody can copy.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A demo walked into the case this was always going to walk into: the Firebase
project had no Android app registered, so there was no google-services.json to
download. The agent driving the scripts needed CREATE_APP=1, improvised around
the missing file to keep the build green, and then stopped without naming a way
forward. The prose forbidding exactly that was already in the skill, so prose is
not where this gets fixed.

The wizard asks. It also hosts the Google sign-in, takes the two Iterable keys
with the echo off, and picks the device by name — three things a conversation
cannot do at all. So it is the default now, and in the routing rather than in
advice: every Google mutation comes back as `run_in_terminal` with bin/handoff,
the block that hands over. DRIVER=agent is the developer asking for the other
path in as many words, and nothing else may set it.

bin/handoff exists so the handover cannot be paraphrased into a command with no
"come back here" on the end — the part that goes missing is always the last
line, and a developer holding a finished terminal and no next step is the other
half of what went wrong in that demo. The wizard prints the return leg on every
exit, including the early ones, for the same reason.

The rest is the same lesson written where it was broken: a green build is not the
deliverable, a missing prerequisite stops the push work before the Gradle edit,
and every turn ends with where it stands, the one thing to do, and an ask to
report back.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The router already sent the Google half to the developer's terminal, but routing
is advice to whoever reads it: an agent that skips `next` still reached the actor,
and one did twice. `bin/provision` enforced consent and never asked whose job it
was, so `bin/onboard --apply` — a command sitting in the skill's own routing table
— ran the whole thing from a chat.

So the actor asks the question too, in the shape the consent gate already uses:
exit 10 and print the handoff block unless a terminal is attached or DRIVER=agent
is recorded in resolved.env. Recorded, not exported, because
`DRIVER=agent bin/provision` is a caller authorising itself. A tty is the one
thing a chat cannot fake, which is also what lets the wizard through without an
exception written for it.

The driver gate runs before the approval gate: asked the other way round, an
agent collects permission for work that was never its to do, and the developer
reads that as having been consulted.

Also splits the skill's routing table, since a model skimming it found the
`provision` row with no marker that the row belongs to the other path.

tests/wizard-decline.exp could not run from the repo at all — it wrote to the
pre-merge `workspace/` path and let the tool resolve a workspace inside itself,
which it now refuses. Given a scratch WS it runs hermetically, and it carries the
one assertion nothing offline can make: that a caller with a terminal gets
through.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The wizard offered to delete the service-account key at rc 40, which is the run
where the Iterable half has not started. Step 3 of that walk is what uploads the
key, so the answer was always no — a rhetorical question, printed under a banner
sending the developer off to do step 1, and left as the last thing on the screen.

Now it is only asked where the answer is not already known. No endpoint can read
the push integration back, so the one state where "finished with this file" is
provable is rc 0: a push arrived, which cannot happen unless the integration
exists and the key behind it was uploaded.

At rc 40 the file is explained instead of asked about, in one dim line, and only
while the walk above it still contains the step that uses it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
"YOUR TURN — the tool cannot do this part" was printed above a command the tool
provides: the four Iterable dashboard steps are theirs to click, and the walk
through them is the tool's. Shipping a command is the difference between "you are
on your own here" and "you drive, it narrates", so the label now says which.

The original wording survives for the case that earned it — a human step with
nothing to run, like plugging a phone in.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
At rc 40 the wizard printed the outstanding-work list and no box. Three
consequences, all of them the same mistake: no single next action, five lines that
look equally ready to run when two of them cannot be, and — worst — the only
command on the screen was `bin/iterable-keys --step 1`, a relative path shown to
somebody standing in their own project, which is the one directory where it does
not resolve. The box exists to carry a path that does.

bin/onboard already did this properly, so the ending is now one function instead
of three copies, and the copy that had drifted is gone. It also stops naming the
next step twice: boxed and then listed again reads as two tasks.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every "run bin/onboard" in this tool was written when it was a repo you cd'd
into. Installed as a plugin, each front end runs from the developer's own
project — the one directory where a bare bin/… cannot resolve. So the next step
the tool named was a command that does not exist, and following it gives you
`zsh: no such file or directory: bin/onboard`.

run_line() prints the resolved path on its own bold line, and every instruction
goes through it. Prose that merely mentioned a command loses the command rather
than gaining a path: G16's advice is "send the proof push", and the box below it
carries the thing to run. The Waiting-behind-it list is dim and unbolded now —
bold was what made those bare paths read as typeable.

Model-facing summaries keep their short form deliberately: a model is handed the
resolved path in next.command, and an absolute path inside a wrapped banner
breaks across lines into something genuinely unrunnable.

tests/no-bare-commands.sh pins it both ways, and found five sites this sweep had
missed. Its second check is scoped rather than global for the reason above.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The absolute plugin path was correct and awful. Two problems, and only the first
is cosmetic: it is too long to type and wraps in a narrow terminal, and hosts
keep old version directories on disk — so a path a developer pastes into a
runbook or an alias keeps working while quietly running the version it was
written against. A broken path at least tells you.

Every entry point they are ever told to run now also exists as a one-line stub
in the workspace we already create and already gitignore, so the messages say
`.iterable/onboard`. Rewritten on every run, which is what makes it safe: a stub
cannot outlive the install it points at without being replaced, and one that
somehow does exits 127 explaining itself instead of failing at exec.

The split is who is reading. next_action's `command` stays absolute, because a
caller with no working directory needs it; cmd_display shortens it for the box a
person reads. Internals get no stub — wizard and provision are dispatched to.

Found while testing this rather than assumed:

- wsp() stripped only $PWD, but $PWD is what the shell was told and WS comes from
  git, which answers physically. On a Mac /tmp is a link to /private/tmp, so every
  shortcut still printed in full. Same symlink that once made a broken test pass.
- G2's summary still quoted a bare `bin/agent set …`, exempted in the lint because
  nothing short enough existed to survive being wrapped. The shim is short enough,
  and that summary is printed inside a box a person reads — the exemption had been
  protecting a real bug. The lint is global now.
- Two assertions pinned the spelling of a printed command. Bare bin/…, an absolute
  path and a workspace stub have each been right at some point; what was wrong all
  three times was a command that did not resolve. They assert runnable now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The commands got shortened; the paths beside them did not. Rendering the whole
flow end to end put `/Users/…/.flowproj/.iterable/.env.template` in the middle of
the Iterable walk, which is the same defect as the untypeable command wearing a
different hat — it is the file they have to go and open.

wsd() shortens a path that already carries the workspace, so the six human-facing
sites read `.iterable/.env.template`. bin/agent's JSON keeps absolute paths: that
is for a caller with no working directory.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`--step 1` was not a special entry point — 1, 2, 3 and 4 all work. The problem is
that only step 4 knew it had a successor. So a developer handed step 1, which is
exactly what the pending ending hands them, did that step and hit a dead end with
nothing on screen saying three more existed. A walk that stops without saying so
reads like a walk that silently failed.

Steps 1-3 now end with the next one, step 4 ends with the ladder that proves all
four, and every step says which of the four it is rather than only the first —
somebody who arrived at step 3 by following step 2 has no other way to know how
much is left.

It worked without this while an agent was pacing the steps, which is the flow it
was written for. The handover has no agent in it.

Two smaller things in the same screen: the usage line printed a bare
`iterable-keys`, and step 3's service-account path stays absolute on purpose —
it goes into a browser file-upload dialog, where a workspace-relative path is no
use. Commented, so the next sweep does not shorten it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Handing a failure over replaced the gate's verdict outright, so `key rejected by
FCM (HTTP 401)` reached the caller as "run the setup in your own terminal". The
remedy with the diagnosis deleted is the one shape a caller cannot recover from,
because it reads as routine — and it is worse for the agent than for the person,
since next.summary is the field the skill relays. It survived only in gates[],
where nothing was looking.

The verdict is kept now, and the box states it before the remedy: opening with
"signs you in to Google" above a rejected credential describes the wrong job, and
a developer who reads the remedy first cannot tell whether the tool understood
what went wrong.

Which needs a repair told from a first run, because the same gate says "not
created yet" on day one and an alarm spent on pending work is ignored from then
on. setup_started() answers it from the artifacts the tool itself produced: once
those exist, a red gate downstream of them is something that broke. No extra
cloud calls, and nothing to keep in sync.

One what-it-does list, not two that drift. The closing line — only what is not
done gets touched — is what makes it true of a repair as well.

Deliberately not done: the deeper version of this is that absence on G5-G9 is
classified red when the file's own principle says the state simply hasn't
happened yet. Fixing that moves a fresh project from rc 20 to rc 40 and the
wizard would stop instead of provisioning, so it is a separate change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…thing

Hand-installing a branch as a real plugin is six steps across two directories and
three JSON files the host owns, and doing it by hand lost the marketplace-name
patch once already. tools/install-local does all of it from a target project
argument, so the next test is one command instead of a runbook.

It refuses two things rather than doing them quietly. A dirty tree, because
git archive ships committed files only: without the check, a test can pass
against a build missing the edit it was testing. And a version already installed
from a different commit, because hosts keep old version directories — a number
that has already been tested must not come to mean different code. Both take
--force, which says what it is not shipping.

Version 26.9.5-beta for the same reason. Overwriting 26.9.4-beta in place would
have left the finished test and the next one indistinguishable, which is the exact
skew the shim work was about.

Stale local builds get pruned, along with records pointing at a version directory
or a test project that no longer exists. The published `iterable` cache is left
alone — it is not ours to prune.

tests/plugin-manifests.sh pins what none of this can see from inside the repo: one
version across all three manifests, one plugin name across both hosts, every listed
skill present with frontmatter and none written-but-unlisted, the two MCP files
still identical, and no path from this machine in anything a client reads.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`claude plugin details` reports the always-on cost, and two of the six agents in it
were build roles: harness-engineer writes the emulator harness, onboard-architect
decides gate-ladder structure. Neither is anything a client could invoke, and a host
discovers agents/ by convention — so being in that directory was the whole reason
they were loaded into every client session.

They move to tools/agents/, which nothing discovers. `make link-agents` links both
directories, so a build machine still gets the whole team.

Pinned as an exact list in tests/plugin-manifests.sh rather than a rule about naming:
adding to what a client pays for every session should take a deliberate edit to that
list, not just a new file in a directory.

26.9.6-beta because the build it produces is different from 26.9.5-beta, and the
installer is right to refuse a number that has already been handed out.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Found while tearing down for a cold test: gcloud's auth had expired, so
`service-accounts describe` failed — and the else-branch read that as "service
account already gone", printed exactly that, then deleted the local key and
declared the Google side back at its original state. Every failing gcloud call
looks the same from in here: non-zero and some text.

The damage is not the wrong message. Deleting the key while the account may still
exist orphans a live credential the tool can no longer see or clean up, and the run
says it cleaned up. Signed out now refuses before touching anything, a NOT_FOUND is
the only thing that means gone, and any other failure prints the error and leaves the
local artifacts where they are.

The closing line was its own version of the same fault: teardown has no standing to
say what the ladder will show. It names the ladder instead.

tests/teardown-honesty.sh stubs gcloud across four states — signed out, refused,
absent, present — and asserts both halves each time: what the screen says, and
whether the key is still there.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`gcloud iam service-accounts describe` on an account that was deleted answers
IAM_PERMISSION_DENIED, not NOT_FOUND — measured against real GCP on 2026-09-22,
even for the caller that had just deleted it. So every "did it fail because it's
gone?" check built on describe was a guess, and two places guessed wrong:

  teardown read any describe failure as "already gone", removed the local key and
  said the Google side was restored. A signed-out or refused run would orphan a
  live credential while reporting cleanup. This is not hypothetical — it happened.

  g6 would have printed PERMISSION_DENIED for a project with no service account,
  making the first Google gate of a cold run look broken instead of pending.

sa_exists() answers in three states off a filtered list, which succeeds or fails
as a whole: 0 present, 1 absent, 2 could not tell. Teardown now stops on 2 and
keeps the key, because deleting it while the account may exist is the worse error.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The project list was a numbered menu you read, then typed a number into. With
several projects that means holding a number in your head while you look away
from the list, and getting it wrong picks somebody else's project.

Arrow keys, enter, and the highlight is where you are. j/k for anyone who
expects them, q or escape to back out, and the numbers still work — they were
in muscle memory and in every note anyone wrote down, so they keep working.

The numbered form stays for the cases the keys cannot serve: a pipe, a terminal
that eats escape sequences, a list taller than the window, MENU_PLAIN=1.

Both live in config.sh rather than the wizard, because the device picker wants
the same thing. Every exit path shows the cursor again — a hidden cursor
outlives the process and leaves the developer's own shell without one.

Arrow keys only exist under a terminal, so the suite for this is expect, not
bash: tests/menu-pick.exp drives all four key families and both cancels.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Four things wrong with the same screen, all visible in one paste from a real run.

Said twice. bin/provision ends by exec'ing the verifier, which printed the whole
rc-40 ending — then the wizard printed it again. Two identical YOUR TURN boxes
read as two separate things to do. The wizard now runs provision with
QUIET_VERDICT=1, since it is the one printing the ending, and its own fallthrough
draws the box rather than telling a developer to scan eighteen rows for the red
one.

Grey for things that mattered. What was still waiting, the key to keep until the
upload, "no push has been proven", the step number in the Iterable walk, the
reason a handoff refused — all dim, which most terminal themes render as grey on
grey. Dim is for asides now: a footnote, a CI alternative, the detail on a gate
that is already green. The rule is written down where dim is defined, and the
command in the blocker box is bold like every other command the tool prints.

The handback asked for a relay. "Go back and tell it you are done" made a person
the transport for a fact already on disk. agent_handback() states it instead, and
comes last: the instruction to the developer first, then the line addressed to
whatever sent them here, naming state.tsv and the gate still open. On an exit
before any gate ran it says so, rather than pointing at a file that is absent or
last run's.

The AI notice was eleven lines, printed at the top of every run and again at the
end — long enough to become the thing you scroll past. Two sentences and a bar,
six lines, still naming the push proof as the only evidence.

tests/ending-shape.sh pins all four: one box per ending, the order of the two
audiences, that stripping every dim segment still leaves the whole instruction,
a per-file count of where dim is allowed, and the notice's length.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
G13 reads a device over adb and G9 waits on IAM propagation; both can sit
for a minute with nothing on screen, and twice in testing that read as the
wizard having hung. The developer's only move at that point is Ctrl-C out of
a run that was working.

So a gate body runs in the background and its row marks time — a spinner
frame and the seconds elapsed. The count is the part that matters: a spinner
says "working", a number is what lets somebody decide between waiting and
giving up. Nothing is drawn for the first two seconds, because most rows
answer inside one and eighteen flickers is worse than none, and nothing is
drawn at all unless stdout is a terminal, so every existing reader gets
byte-for-byte what it always did.

A hidden cursor outlives the process, so bin/gates restores it on EXIT and
on INT — a Ctrl-C out of a device read is the likeliest way out of that
script, and it was leaving the developer's own shell without one.

tests/gate-spinner.exp drives it through a pty, which is the only place this
behaviour exists. It asserts against the whole captured run rather than in
sequence: the bytes interleave on one line, and the cursor-hide arrives
ahead of the first frame, which is how the first version of that file failed
while the code was right.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A real run ended like this: the Google part finished, the ladder printed the
Iterable and device rows as not-done, and the last thing on screen was
SOMETHING IS ACTUALLY WRONG — a defect, not pending work, above an
instruction to re-run the whole tool. The developer had done exactly what
they were told. Nothing was wrong, re-running could not have cleared
anything, and nobody had been shown the four Iterable steps in the first
place.

Two things caused it, and both are fixed here.

Honesty first. token-log.js reported "the SDK ran and never registered" as
red at the deadline, which is two situations wearing one exit code. An app
that has never called setEmail registers nothing and is not broken; an app
whose Firebase failed before the SDK could ask is. They are codes 3 and 4
now: 3 is pending in either of its shapes, 4 is red and carries the Firebase
error, which is the answer. G14's advice reads the verdict rather than
guessing, so "identify a user in the app" is only said when the log shows
that is what is missing.

Then the flow. The run is three parts — Google, Iterable, Device — and it
says so at the top and again as each one starts. After Google is green and
verified, the wizard asks: walk through the four Iterable steps now, or stop
and leave the state for whatever sent you here. The explanation comes before
the question, because the point is not the menu — it is knowing that the
dashboard has no API for any of this, that the device checks have nothing to
find until it is done, and that none of the unfinished rows is a fault in
your app.

Parts, not steps, because parts 2 and 3 have numbered steps of their own;
two depths of "step 3" on one screen is worse than no numbering at all. And
the three endings are written once and reached from both the already-done
path and the post-provisioning one, so a developer cannot tell which branch
printed them.

tests/iterable-part.exp drives all three answers through a pty against a
fixture state.tsv, and pins the one that is easy to lose: it must offer
nothing to somebody who has already finished part 2.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The chat had two screens where the wizard has nine, so an agent that reached
"pick a project" either wrote its own menu or handed the whole job over. Both
were observed live. Every screen the terminal shows now exists as a question:
sign-in, keep-target, project, app, register-app, device, and the four Iterable
steps, three ranked options plus free text.

Consent is a mechanism here, not a paragraph. `discover` refuses to list an
estate without a yes scoped to the Google account; registering an app is its own
yes; the yes has to come off a screen, with a one-time token and a dwell, and
a multi-line value can no longer smuggle APPROVED=1 into resolved.env.

The conduct, which is where the recurring failure actually lived: at G4 red
with every yes recorded, `next` named no command at all, so the handoff block
was the only thing left that produced output. G3 and G4 route to
`onboard --apply`; `project_unreachable` and `register_app` name their own
screens; the `enable_firebase` kind is gone. An empty `next.command` is now a
test failure, and `tests/agent-transitions.sh` walks each screen's own
recommended answer and fails a state that comes back unchanged.

The boxes stopped issuing orders. The agent relaying one was handed the next
step in the same read, so what the developer needs from a box is where the run
got to and what they are being asked to allow — not a command to type. The
terminal block is still one line away, and `handoff` now follows the record
instead of contradicting it.

And the reason none of the earlier fixes reached anybody: the bump workflow
never watched bin/, so a season of changes to the onboarding tool landed on main
with no version change and hosts kept the cached copy.

Contract: `setup.handoff_command` and `setup.drive_it_yourself` are null once
DRIVER=agent is recorded.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
tools/install-local could only enable a branch for a single target project, in
that project's .claude/settings.local.json. That is the right default for a
build under test, but it is not how the published plugin arrives: the host's own
install writes a user-scope record and enables it in ~/.claude/settings.json, so
a one-project install is a rehearsal of a setup no client has.

--user writes that shape instead. The version collision guard, the marketplace
rename, the prune and the structural verify are unchanged; only the record's
scope and which settings file gets the enable differ. A target named alongside
--user is refused rather than ignored, because asking for one project and
getting all of them is not close enough.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two changes, one shape: stop the tool asking for a decision nobody needs to make,
and start it asking for the one thing it cannot do itself.

The device half. `run_app` returned no question once a device was on record —
the comment said building and running are "work, not a decision". Running is,
signing in is not: it happens inside the developer's app, behind their login, and
the silence where a question should have been is what an agent filled by driving
the emulator over adb. Minutes of guessed taps per attempt, no way to tell a wrong
field from a slow screen, and Android's notification dialog answered by a tool on
the person's behalf — then reported back as evidence. So `run_app` keeps a screen
to the end: sign in as this address, allow notifications, and one option re-reads
the device. G13's "permission not asked yet" routes there too, instead of reading
as a reinstall. Both skills now say outright that the gates read `dumpsys` and
`logcat` and never tap, type or launch.

In-app messages. They were an option in the Step 0 scope picker, and they are not
a choice: an initialized, identified SDK fetches and displays them with no code at
all ("there is no need to write any code to get this default behavior"). Asking
implies it can be declined and that saying yes costs something. Off the list, and
onto the handover instead — you support in-app already, and will see nothing until
somebody builds an in-app campaign. Customizing the default is still scope.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
An outside audit of bin/ found this and it reproduced first try: workspace/.env was
`source`d, and it is sourced at the bottom of config.sh — below all 64 functions
defined above it. So a line reading `approved() { return 0; }` in the file that holds
the developer's pasted Iterable keys replaced the consent gate wholesale, and the tool
went on to touch a Google project with no approval record on disk at all.

Two smaller faults in the same call. An apostrophe in a pasted value — `o'brien@` in
ITBL_EMAIL — aborted the source at that line, leaving every key below it unset; G10
then reported that the dashboard steps had never been done. And `source` let the file
outrank the environment, while the file iterable-keys writes says in its own header
that a key exported in your shell "wins over this file". It did not: the template is
written with the keys empty, so sourcing it clobbered an exported one to nothing.

resolved.env was already read key by key, for the same reason, twenty lines into the
file. That loop becomes load_env_file and both callers use it — so APPROVED is ignored
from either file, defined beats cached in both, and neither can execute anything. It
also handles what `source` did and the old loop did not: a quoted value, a leading
`export `, and a last line with no newline.

tests/approval-gate.sh gets the three cases beside the resolved.env ones it already
had. The first fails against the old reader as a live bypass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
iterable-keys tells the developer, in bold, that a key never goes into an argument
list — and then five calls passed one there: `-H "Api-Key: $key"` in itbl_curl, and
`-H "Authorization: Bearer $t"` in api_get, api_post and both direct probes in gates.
An argument list is readable with `ps` by every process on the machine. The promise
was the thing that made this worth fixing: a sentence that specific, in bold, next to
code that does the opposite, is worse than no sentence.

curl_auth takes the headers that carry a credential and hands them to curl on stdin,
as a config file read from `-`. curl's own documentation names this as the way to keep
a credential out of a process list. Callers keep everything else in argv, because
nothing else there is secret — the URL, the method, the JSON body.

Verified against curl 8.7.1, including a value containing a quote and a backslash,
which the config format escapes and which a key never contains anyway.

tests/no-raw-http.sh grows the lint, since the wrapper only helps while everything
goes through it. Comment lines are excluded on purpose: curl_auth's own note quotes
the banned form to say what it replaced.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two findings from the same audit, both hidden behind code that ended correctly.

save_env chmod'ed .env to 0600 as its last act, but built the new contents in
.env.tmp created at the caller's umask — 0644 nearly everywhere — holding every key
except the one being written. `mv` carries a mode with it, so .env arrived
world-readable too and the chmod closed a window it had just opened. The umask idiom
this wanted is ten lines away in iterable-keys. The chmod stays, because a umask
cannot fix a file that already exists at 0644.

The google-services.json download put its redirect on the target: `api_get … >
"$GS_JSON"` truncates before the fetch it depends on has run. A re-run that failed on
an expired token left an empty file where a good one had been, and G5 then called the
config file unparseable rather than calling the download failed — which sends the
developer to look at Firebase instead of at `gcloud auth login`. It writes a .part
file and moves it, and it moves to config.sh so a test can stub api_get.

tests/workspace-writes.sh covers both, since they are one promise: a write into the
workspace leaves no file worse than it found it. The mode case intercepts the `mv` to
read the temp file as it is handed over — the end state was always right, so a test
that only checks the end state passes against the bug.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The branch below this one already refuses for exactly this reason: when the read of
the service account fails, teardown prints the error and leaves the local artifacts
alone, because deleting a private key while its account may still exist orphans a live
credential nothing can see. The delete failing is the same situation one step later,
and it fell straight through — `gcloud … delete && echo` only skipped the message, and
`rm -rf artifacts/` ran regardless. So a delete refused by IAM produced a run that
removed the only copy of the key and said nothing about it.

Same bug class as the fix in effbc54, in the adjacent branch of the same file.

tests/teardown-honesty.sh gets a fifth stubbed state — account present, delete refused
— and asserts what the other four do: what the screen says, and whether the key is
still on disk.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Twenty test files, four hundred and thirty-five checks, and no way to run them but one
at a time by hand. CI never mentioned tests/ or bin/ at all, so every one of these
suites was as good as the last person who remembered it — the same shape of problem as
a version that never got bumped: real work that reaches nobody.

`make test` and a macOS job in validate.yml. macOS because that is where the tool runs,
on a machine that already has adb and gcloud, and because its /bin/bash is 3.2 — it
fails on a bash-4 construct that ubuntu would accept. Nothing here uses one today, and
now nothing can start to. expect ships with macOS, so the interactive suites run too.

Three suites were not executable, which is invisible until something loops over the
directory. The runner invokes `bash "$t"` so a mode cannot drop a suite silently, and
reports the missing bit separately; they are chmod'ed here as well.

A suite that skips is reported as skipped and never counted as a pass, with its own
reason on the line. Four suites here need a live project, a device or the network, and
"nothing ran" reading as green is exactly how a release that reached no one looked fine.

validate.yml's paths grow to cover bin/, tests/, agents/, the two new skills and the
Makefile — a workflow that does not trigger is its own kind of green.

26.9.12-beta: the installer is right to refuse a number already handed out, and the
five fixes under it change the build.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The shell job's first run on a clean macOS runner failed two suites that pass on a
developer's laptop, which is the job earning its keep before it had reviewed anything.

REQUIRED_TOOLS is (node gcloud adb java) and next_action reports install_tools ahead of
every other rung when one is absent. A runner has node and java and neither gcloud nor
adb, so 6 assertions in agent-questions.sh and 3 in ending-shape.sh were asserting about
the install branch rather than the state they set up. Not a product bug: install_tools is
the right answer on a machine with no gcloud. The suites simply never established the
precondition they were testing, and inherited it from whoever ran them.

Supply the tools rather than hide them. ending-shape.sh reasoned the other way in a
comment — that stubbing tools away would derail the branch under test, so PATH was left
alone — which is true, and still leaves the verdict to the host.

The stub gcloud logs and exits 1, so these suites cannot quietly start calling Google; the
stub adb answers only the dialects a screen needs to name a device. device-pick.sh keeps
its own richer adb, because it tests the resolver rather than using it.

Both suites now report the same number of checks with the real tools present and absent,
and so does the full run: 435 either way.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The two docs/plans files were internal working material: the 968-line
implementation plan and the plan for this merge. On a public repo they
publish a debugging log, three test identities, and a gate count and a
workspace path that both stopped being true weeks ago. Nothing in bin/
reads them.

Deleting them leaves INTENDED-FLOW.md as the only document claiming to
be the contract, so the claims in it have to hold:

- It named `agent-path-auditor` as the thing that checks the tool
  against it. No such agent exists. Four suites actually do that job,
  so they are named instead.
- onboard-architect was told the deleted plan was its source of truth
  for "the sixteen gates". There are eighteen, and the file said so
  four lines further down. It now reads the contract.
- Nine paths across three shipped agents and bin/README pointed at
  `workspace/`, which bin/ has not used since the workspace became
  `.iterable/`. An agent following those looks in a directory that is
  not there.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The README described a documentation corpus. The plugin provisions Firebase,
writes the integration and proves a push landed on a device, and none of that
was written down: a reader had no idea two of the four skills existed, that
anything needed gcloud, or that a device was required at all.

Modelled on OneSignal's plugin README, which solves the same problem — a skill
table with the funnel order, prerequisites that say what happens when a tool is
absent, and one security section a reader can take to their own review.

The prerequisites table is the part that was actually missing rather than merely
undocumented. G0 tells a developer to install gcloud and adb and does not say
how; every other blocked state in this tool names its remedy. Now the README
does, with the checked cask names.

Also fixes the Cursor and Codex symlink loops, which installed two of four
skills.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ts read

Skill selection happens on the description, never the body. So the two
integration skills advertising no route to iterable-provision meant the
routing existed only where nothing looks: a developer whose Preflight hit a
missing google-services.json got a skill that knows it cannot proceed and no
skill that can fix it. The clause names the three inputs that are missing
rather than wrong, and repeats the prohibition on inventing one — the failure
being defended against is a fabricated config, which is cheaper for a model
than admitting the gap.

The plugin description promised "an Android or React Native app ... proven on
a real device". For a React Native app targeting iOS none of that is true:
provisioning is Firebase, and the proof reads an Android notification service.
It now says Android-first and scopes the RN claim to integration guidance.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Nobody wrote a README for bin/. It is the standalone repo's root README,
renamed in by the subtree merge (R100 at 8336433), and it still read like one:
`# iterable-onboard`, `## Status`, `## Getting started`, `## The team` — a
product page for something that stopped being a product, now duplicating the
repo README and shipping to clients under a name that appears nowhere in the
plugin.

Three defects came from the rename itself:

- `## Getting started` was `./bin/onboard` and the words "That's it." A client
  cannot type that; bin/ does not exist in their project. It is the exact
  failure no-bare-commands.sh was written for, and line 28 of that lint skips
  *.md — so the file most likely to say it was the one file never checked.
- `[agents/](agents/)` resolved from the repo root and now resolved to
  bin/agents/. The only broken link in the file.
- A sentence contained itself twice, from a bad edit nobody re-read.

The lint now covers it, narrowly: the README cannot take the no-bare-bin rule
because it names bin/gates on nearly every line, so instead it may not contain
the `./bin/` form a reader copies into a shell, and it has to state which
directory its paths are relative to. Both checks fail when the old section is
pasted back.

Kept every piece a maintainer needs and nothing a customer does: the three
front ends, the `next` protocol, the DRIVER and consent mechanisms with the
one-time token, the per-gate device reads, exit codes and why 40 exists, the
workspace layout, and why the stubs exist.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`java` has been in REQUIRED_TOOLS since the line was written — 6b4cfa6, the commit
that introduced both the array and G0's tool check — and nothing has ever invoked it.
No java, no keytool, no jarsigner; the one `gradlew` in the tree is advice text
bin/gates-device.sh prints. The comment above the array is about an IFS quoting bug
and names java only as sample text, so there was no reason recorded anywhere.

A missing tool outranks every gate in next_action, so a developer without a JDK on
PATH got G0 red, kind install_tools and exit 10 with the entire ladder parked —
including the device half, which reads through adb and needs no JDK. G0's own
summary, "without them the ladder cannot check those rungs at all", was false for
java specifically.

The two documents explaining java disagreed with each other, which is the tell:
iterable-provision/SKILL.md called it "the keystore reads" (there are none) and the
README called it "building your app" (true of the developer, not of this tool). The
README keeps its three real tools and says outright that your JDK and Gradle are
yours; the skill row drops the claim.

Four suites stubbed a java that only had to exist, and stub-toolchain.sh said so in
a comment. They no longer need it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@franco-zalamena-iterable
franco-zalamena-iterable merged commit c675844 into main Sep 25, 2026
2 checks passed
@franco-zalamena-iterable
franco-zalamena-iterable deleted the feat/one-plugin-provision-verify branch September 25, 2026 15:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant