Repository navigation
[SDK-756] Two new skills that take Android push from nothing to a proven delivery - #24
Merged
franco-zalamena-iterable merged 46 commits intoSep 25, 2026
Merged
Conversation
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>
… 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
deleted the
feat/one-plugin-provision-verify
branch
September 25, 2026 15:13
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 appand its real
google-services.json, a service account scoped to sending push plus itskey, and the four Iterable dashboard steps. It is what
iterable-android/iterable-react-nativenow route to when Preflight reaches an input nobody has, insteadof stopping.
iterable-verify— sends a real push and reads the device's own notification serviceto 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/provisionandbin/proof-pushdo things;bin/gatesjudges them and can do nothing. Two gates cannot be faked — G9 mints a tokenfrom the service-account key and makes FCM accept it, G16 finds the push in
dumpsys notificationby 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
~ unverifiablerather than a tick, and exit 40 exists so that "nothing isbroken 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/wizardfor a human,bin/agentemitting JSONfor 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.mdis thecontract 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 outsideagents/, so aclient 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, orthat a device was required at all. It gains a skill table with the funnel order, a
prerequisites table with install routes —
G0tells a developer to installgcloudandadband does not say how, which is the one blocked state in this tool that never named itsremedy — 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 logand 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.gates, exit 0, G16 matching the send's own marker two seconds later.
pnpm check:allgreen.Both are now reachable from CI. They were not: nineteen suites existed and nothing ran
them, so
validate.ymlgets amacos-latestjob — macOS because that is where the toolruns and because its
/bin/bashis 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:
.envwassourced, so a file in the workspace could redefineapproved()and forgeconsent, set
APPROVED=1, or clobber a key exported in the shell. It is now parsed.curl -H, readable viaps. They now go through a curlconfig file on stdin.
save_envleft a 0644 window on a temp file holding every other key.google-services.jsonfetch truncated the good file, because the redirect wason the target.
teardowndeleted 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
gcloudand the agent attaches to that session. It never defeats aCAPTCHA 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-operatoris the only agent with browser access and is origin-confined.
gate-auditoris read-onlyand 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:
INTENDED-FLOW.md— the contract. If you disagree with anything, disagree herefirst; the code is downstream of it.
bin/gates— the judge. It can do nothing, and that is the point.bin/config.sh— the spine, and where four of the five blockers lived. It is onelong file on purpose: see the
.envnote, whose blast radius was 64 functions.SKILL.mdand the routing edits in the two existing ones.bin/questions.sh+tests/agent-questions.sh— the screens, and the proof thatthe 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 —
fca2a61is a single idea across 25 files — so adirectory-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.shasserts 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
pnpmpin for the bumpworkflow, broken on main today — is split out separately.
What to push on in review
(
iterable-react-native/SKILL.md) —iterable-verifycovers Android first.teardownleaves.envand the approval marker on disk on purpose (they are thedeveloper's own credentials), so a "cold" re-run is not fully cold.
iterable-android/SKILL.mdis 663 lines and wants cutting toward 500.Manifest version goes to
26.9.12-beta, and this PR also addsbin/**,agents/**andthe 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