* fix: bound the iOS system-modal snapshot probe to the capture deadline (#1244)
The pre-plan SpringBoard system-modal probe (`blockingSystemAlertSnapshot`)
ran before `runSnapshotCapturePlan`, outside the 20s plan budget, the bounded
main-thread watchdog, and the XCTest recovery envelope. A slow
`springboard.alerts`/`sheets`/descendant enumeration (seen on
ASWebAuthenticationSession consent and notification-permission dialogs) could
therefore stall `snapshot -i` for 30-39s.
Run the probe as a bounded capture tier instead: it shares the snapshot plan
deadline and executes on the same `runMainThreadWork` watchdog as the tree and
query backends (`systemModalProbeBudget`, clamped by the remaining deadline).
On timeout the abandoned probe is tracked like an abandoned tree capture, so the
plan recovers through the independent (private-AX on simulator) backend, later
commands fail fast as busy instead of queueing behind it, and the runner is
never wedged. Diagnostics identify the probe and its elapsed time.
The shared abandonment bookkeeping used by both the tree capture and the modal
probe is extracted into retain/release helpers.
* fix: don't re-enter main for bookkeeping behind an abandoned snapshot probe (#1244)
After the bounded system-modal probe times out, runMainThreadWork abandons its
query but that query keeps grinding on the main thread. The capture plan recovers
through the independent private-AX backend and returns — but executeSnapshotDispatched
then ran its didRecordXCTestFailure/retry bookkeeping through another runMainThreadWork
hop, which queues behind the same abandoned query and re-stalls the command for up to
the 30s execution watchdog (or throws), reintroducing the very stall the recovery
avoided. Skip that bookkeeping while abandoned XCTest work is outstanding — the policy
setNeedsPostSnapshotInteractionDelay already uses — so the recovered response returns
immediately and a subsequent command still reports RUNNER_BUSY until the work drains.
Adds an in-bundle regression that fails if the guard is removed. The snapshot recovery
loop is factored into executeDispatchedWithRecovery so the guard is exercisable without
a live capture.
The alert command's SpringBoard detection is intentionally left on its existing 30s
command watchdog here; giving alert its own physical-iOS capability is tracked as a
focused follow-up (#1231).
* test: force the bounded system-modal probe timeout through snapshotFast
Adds a minimal probeWork seam to boundedBlockingSystemAlertSnapshot (threaded
through snapshotFast/snapshotRaw, defaulting to today's exact production
closure) so a test can substitute a blocking probe body while still running
the real runMainThreadWork wrap and the real onAbandoned/onDrained hooks.
The new regression test drives snapshotFast (the real entry point, not
boundedBlockingSystemAlertSnapshot directly) with an injected probeWork that
blocks past the probe's slice, forcing a genuine timeout, and asserts in
order: busy/penalty accounting once the timeout fires, a recovered payload
returned while the probe is still abandoned (before drain), and release
(hasAbandonedTreeCapture() false / idle) once the probe drains. Verified
revert-sensitive: bypassing the runMainThreadWork wrap, or dropping the
onAbandoned/onDrained hooks, each turn this test red.
* ci(ios): run the #1244 system-modal probe regressions so a wrapper/hook revert is caught
* fix: gate the #1244 probe-timeout test seam behind the unit-test flag
Addresses PR #1248 re-review blockers:
1. `probeWork` was a test-only DI parameter on the production signatures of
`snapshotFast`, `snapshotRaw`, and `boundedBlockingSystemAlertSnapshot`.
Gate it behind `#if AGENT_DEVICE_RUNNER_UNIT_TESTS` (the same active
compilation condition the build script already sets via
AGENT_DEVICE_XCUITEST_INCLUDE_UNIT_TESTS, and the same one this file
already uses to gate its unit-test methods): production callers now
compile the original, unparameterized signatures byte-identical to
pre-`probeWork`. Both overloads of `boundedBlockingSystemAlertSnapshot`
delegate to a new private `boundedBlockingSystemAlertSnapshotBody`, which
is the only place the real `runMainThreadWork` wrap and the real
`onAbandoned`/`onDrained` hooks are defined, so a revert there fails
through both the production and the test-seam overload.
2. The regression test previously asserted `.idle`/`hasAbandonedTreeCapture()
== false` right after signaling the probe's release semaphore, racing the
drain instead of synchronizing on it. It now polls
`hasAbandonedTreeCapture()` (bounded) on the same background queue after
signaling release, fulfilling a dedicated `drained` expectation that the
test `wait(for:timeout:)`s on before the release assertions, so a slow or
missing drain fails deterministically instead of racing.
3. The regression only drove `snapshotFast`, leaving a `snapshotRaw`-only
wrapper regression uncaught. The test body is now a private helper
parameterized over the entry point, called once for `snapshotFast`
(existing test, unchanged name) and once for `snapshotRaw` (new
`...ForSnapshotRaw` test), keeping ios.yml's `-only-testing` list in sync.
Verified revert-sensitive for both entry points: temporarily bypassing the
bounding wrap in `snapshotFast` or `snapshotRaw` turns each entry point's own
test red (and only that one); dropping the onAbandoned/onDrained hooks in
the shared body turns both tests red. Restoring the code turns all green
again.
* fix: collapse snapshotFast/snapshotRaw to one production entry point
Reviewer blocker on #1248: the previous #if/#else split compiled a unit-test
overload with a probeWork parameter that the regression tests called, while
shipping builds compiled a separate #else implementation that no test ever
exercised. Reverting the shipped snapshotFast/snapshotRaw/
boundedBlockingSystemAlertSnapshot to bypass the bounded probe would have left
the tests green.
Collapse each command to a single, always-compiled production implementation
(no probeWork parameter anywhere), and move the only injectable seam to a
tiny systemModalProbeOverrideForTesting property (stored on RunnerTests since
extensions can't hold stored properties) consulted from inside
boundedBlockingSystemAlertSnapshot's probe closure. Tests now call the real
snapshotFast/snapshotRaw entry points and set the override instead of passing
probeWork.
* chore: close out architecture experiments
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* docs: record unavailable live experiment
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* ci: make Android perf script atomic
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* docs: explain atomic perf workflow
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* chore: simplify back-edge diagnostics
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
---------
Co-authored-by: Michał Pierzchała <thymikee@gmail.com>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* refactor(command-descriptor): keep owner-file claims tooling-only
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* refactor(daemon): keep daemon-route owner-file claims tooling-only
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* test(daemon): guard against re-adding owner-file paths to the production route chain
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* refactor(command-descriptor): derive owner-file projection from colocated RAW_COMMAND_DESCRIPTORS
- Keep ownerFiles on each RAW_COMMAND_DESCRIPTORS entry as the source of truth.
- Add tooling-only __OWNER_FILES__ build flag so production bundles omit the
ownerFiles properties entirely.
- Derive COMMAND_OWNER_FILES from RAW_COMMAND_DESCRIPTORS instead of a
hand-maintained parallel table.
- Guard command-explain tests against leaking ownerFiles into production
descriptor objects.
- Enable treeshake.propertyReadSideEffects: false in tsdown to help drop the
dead ownerFiles branch from production bundles.
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* chore: apply oxfmt formatting
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* test(build): guard tooling metadata exclusion
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* refactor(command-descriptor): drop global treeshake option and add bundle guard
- Remove treeshake.propertyReadSideEffects from tsdown.config.ts; the
__OWNER_FILES__ define + conditional spread already keeps owner files out
of the bundle, so the global DCE lever is unnecessary and scope-creeping.
- Add a comment on the __OWNER_FILES__ global declaration explaining the
deliberate type-versus-runtime mismatch.
- Add test/output-economy/owner-files-no-leak.test.ts to build dist and
assert that no command or daemon-route owner-file path appears in the
emitted JS.
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* fix(build): remove owner metadata property reads
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* fix(command-descriptor): enforce owner claim totality
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
---------
Co-authored-by: Michał Pierzchała <thymikee@gmail.com>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* feat: add Android test IME helper for deterministic text entry (#1198)
Ships a headless InputMethodService (android-ime-helper) as a third Android
helper APK, replacing the visible system keyboard during automated sessions.
Renders zero accessibility nodes and accepts Unicode/CJK/emoji text over a
base64-encoded broadcast channel, fixing both the settle-diff IME-chrome
flood and the ASCII-only adb-shell text entry limit in one structural fix.
- android-ime-helper/: InputMethodService + build/package scripts on the
existing helper-APK toolchain (javac+d8+aapt2+zipalign+apksigner).
- src/platforms/android/ime-helper.ts, ime-lifecycle.ts: install/version
lifecycle (shared with the other two helpers via the new
helper-package-install.ts), activation on session open, and on-device
restore-hygiene (previous IME persisted to a device settings key so any
daemon/state-dir can recover it; restored on close, daemon teardown, and
daemon startup for orphans left by a crashed run).
- input-actions.ts: fill/type route through the helper's broadcast channel
when active, unicode-safe; unchanged ASCII-shell fallback otherwise.
- doctor: new android-test-ime check flags a stuck helper IME with a
copy-pasteable `adb shell ime set` remediation command.
- Gating: default-on for emulators, opt-in via `open --test-ime` on real
devices.
- Dead-weight: rewrote the manual ADBKeyBoard workaround doc, dropped the
now-provably-live skillgym non-ASCII eval case, updated the ASCII
fallback's error message to point at the helper instead of dead-ending.
🤖 Generated with Claude Code
Co-Authored-By: Claude <noreply@anthropic.com>
* fix(#1201 review): permission-gate the IME receiver, fix CI, add opt-out
Addresses the independent review's blockers and should-fixes.
SECURITY (blocker 1): the text-injection receiver was RECEIVER_EXPORTED with
no gate — any co-installed app could inject text into the focused field while
the test IME was active. Fixed by requiring the WRITE_SECURE_SETTINGS sender
permission on the (in-process, dynamically-registered) receiver: adb shell
holds it, third-party apps cannot. The reviewer's suggested exported=false +
explicit-component approach was tried first but empirically breaks delivery on
API 36 (adb shell cannot reach a non-exported receiver there) — documented in
the helper README. Live-verified: a purpose-built rogue APK's broadcasts
(implicit and package-scoped, no permission) are silently dropped, field
unchanged; adb shell's bare broadcast still injects. Added
ime-helper-security.test.ts asserting the permission gate and that no
permissionless exported registration returns.
CI (blocker 2): (a) added `testIme` to integration-progress-model flag buckets
(Integration Tests was red on the unclassified flag). (b) mocked
resolveAndroidImeHelperArtifact in session-doctor-android / ime-lifecycle /
input-actions-test-ime tests so they no longer depend on android-ime-helper/dist
existing on disk (Coverage was red on a fresh checkout); verified by running
them with dist removed.
Should-fixes: added `--no-test-ime` to opt out on emulators (tri-state gating,
parser-tested); PR body's "byte-identical" claim corrected to size/CRC-match.
🤖 Generated with Claude Code
Co-Authored-By: Claude <noreply@anthropic.com>
* docs(#1201): pin the API-36 exported-receiver constraint in a comment
The RECEIVER_EXPORTED flag cannot express why it must stay exported. Add a
one-line note so a future hardening pass doesn't switch to RECEIVER_NOT_EXPORTED
and silently break the CLI (adb shell can't deliver explicit broadcasts to
non-exported components on API 36+; WRITE_SECURE_SETTINGS is the actual gate).
🤖 Generated with Claude Code
Co-Authored-By: Claude <noreply@anthropic.com>
* fix(#1201 review): harden IME restore lifecycle (blockers 1 & 2)
Blocker 1 — a failed restore no longer deletes the recovery value. restore now
reads back default_input_method after `ime set` and only clears the persisted
previous-IME record on a confirmed-successful restore; a failed set keeps the
value so a later retry / startup recovery / doctor remediation can still
un-strand the user off the helper IME.
Blocker 2 — startup orphan-recovery no longer overwrites/races user state.
It only restores when the device's CURRENT default IME is still our helper
(so a user who legitimately switched away is left alone), and skips any device
a live session in this process owns (the fire-and-forget startup vs. concurrent
`open` race — activate now marks the device active BEFORE the `ime set`, so any
recovery pass that could observe the helper active also observes the flag and
skips). Never persists the helper itself as the previous IME. activate also
verifies its own switch via read-back.
Exported ANDROID_IME_HELPER_SERVICE_COMPONENT so restore compares the active IME
without reading the packaged artifact from disk. Tests: failed-restore keeps the
value (+ later recovery succeeds), startup no-op when current != helper, startup
skips a live-owned device.
🤖 Generated with Claude Code
Co-Authored-By: Claude <noreply@anthropic.com>
* chore(#1201): delete unused ACTION_ENTER path, baseline test-only export seams
Rebased onto main (#1202 production-unused-exports gate). Two follow-ups:
- Deleted the unused ACTION_ENTER broadcast end-to-end (TS sendAndroidImeHelperEnter
+ its test, Java handler, README): nothing routes through it — `keyboard enter`
uses the keyevent ENTER path — so the new production-exports gate flagged it as
dead production code. Removed rather than grandfathered.
- Added the three legitimate test-only seams (resetAndroidImeHelperInstallCache,
resetAndroidTestImeActivationCacheForTests, setAndroidTestImeActiveForTests) to
fallow-baselines/production-unused-exports.json, matching how the sibling helper
reset functions (resetAndroidMultiTouchHelperInstallCache, ...) are already
grandfathered there.
🤖 Generated with Claude Code
Co-Authored-By: Claude <noreply@anthropic.com>
* fix(#1201): stop daemon-startup adb spawn on non-Android hosts (macOS Smoke)
Root cause of the red macOS Smoke shard (proven, not hand-waved): the
fire-and-forget restoreOrphanedAndroidTestImeOnDaemonStartup ran `adb devices`
at EVERY daemon startup, on every platform. GitHub macOS runners ship the
Android SDK, so this cold-started the adb server mid-replay and destabilized the
macOS System Settings replay timing — the failed job's cleanup shows
"Terminate orphan process: pid (N) (adb)"; main's green runs spawn no adb.
Fix: gate the startup orphan scan behind a host-side marker written in the
daemon state dir when a session activates the test IME (mirrors the
managed-web-browser orphan-cleanup `installed` gate). A host that never uses the
Android test IME — the macOS CI runner included — never writes the marker and so
never spawns adb at startup. The marker is cleared once nothing is left stuck.
Adds SessionStore.resolveStateDir(); tests: startup recovery does not scan adb
when no marker exists (+ marker cleared after a clean scan).
🤖 Generated with Claude Code
Co-Authored-By: Claude <noreply@anthropic.com>
* chore(#1201): suppress fallow class-member false-positive on state-dir accessor
CI's Fallow audit flags SessionStore.resolveDaemonStateDir as an unused class
member, but it is called via sessionStore.resolveDaemonStateDir() in
session-open.ts — fallow's class-member tracer just doesn't resolve a method
call sited inside a call argument. Renamed for clarity (avoids the collision
with config.ts's free resolveStateDir) and added the localized
fallow-ignore-next-line unused-class-member suppression.
🤖 Generated with Claude Code
Co-Authored-By: Claude <noreply@anthropic.com>
* fix(#1201 review): durable persist before switch + device-scoped recovery markers
Addresses devin-ai-integration's two P1 restore-safety blockers on 19cbce79d.
P1.1 — durably persist the restore target BEFORE the global IME switch.
writePersistedPreviousIme now checks the `settings put` exit code AND reads the
value back, returning a boolean. activate persists first and, if it cannot be
persisted, fails open to the existing input path WITHOUT switching — a rejected
`settings put` can no longer strand the user on the helper with no restore
target. Regression test added.
P1.2 — close the marker crash/offline blind spot. Recovery intent is now
recorded per device, BEFORE the switch (ordering: durable record -> marker ->
ime set), eliminating the post-switch/pre-marker crash window. Markers are
device-scoped and each is retained until that device is actually observed clean:
an offline/disconnected-but-stuck device keeps its marker and is recovered on
reconnect instead of being cleared because the current `adb devices` scan saw no
set-failed. Close-time restore clears only that device's marker (stateDir plumbed
through teardown/close). Tests cover the persist-failure, post-switch/pre-marker
crash, offline-then-reconnect, live-session-owned, and user-switched-away cases.
🤖 Generated with Claude Code
Co-Authored-By: Claude <noreply@anthropic.com>
---------
Co-authored-by: Claude <noreply@anthropic.com>
* feat: add derived fail-open check:affected selector
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* refactor: simplify selector for complexity gate; add docs
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* fix: fail open on ambiguous non-source fixtures; guard catalog against real package.json/vitest.config
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* refactor: use src/utils/exec.ts process helpers in check:affected runner
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* fix(check:affected): SkillGym ownership, honest catalog, working-tree discovery
- Add SkillGym ownership for skills/ and test/skillgym/; stop short-circuiting
their Markdown as docs-only (findings 2 & 4).
- Drop the fabricated GitHub 'SkillGym' job: it is a local-only gate, now
localRunnable with no CI job, guarded by a workflow-existence self-test (3).
- Fold working-tree (staged/unstaged/untracked) state into local discovery and
disable rename detection so both rename paths classify (1).
- Add run.test.ts entrypoint regressions (real diff/status/rename discovery,
--run order/skip/stop-on-failure).
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* fix(check:affected): union staged + unstaged diffs so they cannot cancel
A single `git diff HEAD` nets index against working tree, so a staged add
and an unstaged delete of the same file cancel and hide it. Collect
`--cached` (staged) and unstaged diffs separately and union them; add a
cancellation regression test.
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* fix(check:affected): cover required suite gates
* refactor(check:affected): delegate tests to vitest
---------
Co-authored-by: Michał Pierzchała <thymikee@gmail.com>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* ci: ratchet against test-only exports
Three exported-and-unit-tested-but-unreferenced-in-production incidents
this week (#1166 getNearestCommandNames, #1167 buildSettleTail, #1199
clearMetroSessionHints) — the first two were caught by fallow's dead-code
check because they had zero importers anywhere; #1199 was missed because a
test file imports the export, and fallow's default reachability graph
counts a test import as "used".
Adds a second, stricter pass reusing fallow's own --production mode
(entry.exclude test/story/dev files) via scripts/test-only-exports/check.ts:
an export alive in fallow's default graph but dead in its production graph,
with no other reference anywhere in its own file, has no production call
site — exactly the #1199 shape. Ratchets against a checked-in baseline
(scripts/test-only-exports-baseline.json, 77 entries); new findings fail
`pnpm check:test-only-exports` (wired into CI's Fallow job and
check:tooling). A `// test-seam: <reason>` comment above an export is the
escape hatch for intentional test seams.
Also extends .fallowrc.json's ignoreExports for seven daemon route handlers
(src/daemon/handlers/*.ts) that are genuinely production-reachable through
request-handler-chain.ts's `typeof import()` lazy-load pattern, which
fallow's static import graph can't trace as a named-export consumer —
without this they were false positives in the production-mode pass.
* fix: harden test-only-exports ratchet per review
Addresses the two should-fixes and all five minors from the independent
review of #1202:
- Replace the regex own-file occurrence count with an oxc-parser AST walk
(typescript@7 ships no JS scanner API, so the review's fallback tool
suggestion is the primary): identifiers are counted as AST nodes deduped
by source span, so mentions in JSDoc/block comments, strings, and
template-literal text no longer masquerade as call sites (review finding
1, both constructed cases re-verified fixed), and a `//` inside a string
no longer hides real usages (finding 6). Span dedupe keeps barrel
re-exports (`export { x } from`) counting once. The sharper count
surfaced one organic false negative on main: `selector` in
src/commands/index.ts was previously exempted because the regex matched
"selector" inside the './...selector-read.ts' import path string; it is
now baselined alongside its sibling `ref` (same re-export line).
- Make the baseline shrink-only (finding 2): --update-baseline refuses new
findings with the same wire/delete/annotate message, so the `// test-seam:`
annotation in the reviewed source diff is the only acceptance path;
CONTRIBUTING no longer documents baseline regeneration as an acceptance
option and now describes baseline growth as a deliberate manual edit.
- Stale baseline entries now emit a `::warning` CI annotation (finding 3).
- Commit a re-runnable fixture test (finding 4): check.test.ts mirrors
scripts/layering/model.test.ts, builds a synthetic package with a
clearMetroSessionHints-shaped export (JSDoc self-mention included),
asserts it is flagged, and asserts the annotated twin passes; wired
before the check in pnpm check:test-only-exports.
- Mark the unreadable/unparseable-file fallbacks CONSERVATIVE: per
CONTRIBUTING's convention (finding 5).
- Document the dynamic property access (obj[name]) blind spot in the
script header and CONTRIBUTING (finding 7).
* fix: harden test-only export ratchet
* refactor: use native Fallow export gate
* chore: refresh production export baseline
Three measured dev-loop/CI cuts, no signal loss:
- typecheck now runs tsgo (already trusted for declaration emit by the
tsdown build): 21.7s -> 5.3s locally, and check:tooling drops to ~18s
total. tsc stays available as typecheck:tsc; verified tsgo fails on
type errors and respects noUnusedLocals.
- remove the Unit Tests CI job: Coverage runs the same unit +
provider-integration suites under coverage thresholds, so the job
reran ~64s of tests every PR for no extra signal.
- Size workflow: skip docs-only paths (same paths-ignore as CI) and
cache the base commit's dist keyed on base SHA, since dist is fully
determined by that commit. Startup medians are still measured fresh
on the same runner so the base/PR startup comparison stays
same-machine; the cache is saved immediately after the base
measurement so the PR build never poisons it.
Claude-Session: https://claude.ai/code/session_01FqeW8sA2ZnvnftdvpCqFMS
Co-authored-by: Claude <noreply@anthropic.com>
* docs: retire plans/perfect-shape.md — roadmap complete
The perfect-shape roadmap (two-registry thesis: CommandDescriptor +
PlatformPlugin, typed-result spine, folder DAG + layering lint, agent-cost,
and the Apple apple+appleOs platform model with a non-breaking leaf wire) is
substantively complete and merged. Per its own §5 retirement note, the durable
decisions now live in ADR-0008 (command descriptor) and ADR-0009 (Apple/AppleOS),
and current-state terms in CONTEXT.md; this removes the last plan file.
- Delete plans/perfect-shape.md (plans/ is now empty and gone).
- CONTEXT.md: add "Architecture (perfect-shape refactor, completed 2026-07)"
end-state summary plus a "Deferred / next-minor" note (Phase 2c client-types
narrowing, b.3 recording/providers facets, strict DAG back-edge inversion,
legacy alias drops) so nothing is lost.
- Repoint every remaining perfect-shape.md/§ reference (ADRs 0003/0008/0009,
ci.yml, scripts/layering/check.ts, and the platform-plugin/apple comments)
to ADR-0008/0009 or CONTEXT.md. No dangling references remain.
Docs/comment-only; tsc, oxlint, oxfmt, and the layering DAG check all pass.
* docs: repoint dangling perfect-shape section refs before retiring the roadmap
Removing plans/perfect-shape.md left three comments citing bare section numbers
with no surviving target. The rationales are already inlined, so drop the numbers
(and point the do-not-flatten note at the durable ADR):
- src/platforms/apple/plugin.ts: `(§7)` -> "do-not-flatten; see docs/adr/0009".
- src/core/interactors/register-builtins.ts: "the §5.1 ... sketch" -> "an ... sketch".
- scripts/layering/check.ts: drop `(§5.5 ...)`, keep the inline "re-export barrels only".
Generalize the inline CI "Layering Guard" grep into a structured
import-direction lint (scripts/layering/check.ts) over the resolved
import graph, per plans/perfect-shape.md §5.5.
The full target DAG (kernel ◄ platforms ◄ core ◄ commands ◄ {cli,
client, daemon/server}; client ◄ daemon/client) is only partly realized
— the client/remote/metro extraction, the daemon/server split, and the
utils dissolution are still pending Phase-5 moves, so the tree still
holds legitimate back-edges (platforms→core, commands→cli, utils→*).
Enforcing the whole DAG today would need a mass import rewrite that
Phase 5 defers. The lint therefore enforces the three invariants the
completed moves (kernel/, daemon/client/) already guarantee and that are
green today:
R1 kernel-sink — nothing under src/kernel/ imports another zone,
except the one type-only kernel→contracts re-export.
R2 commands-floor — nothing below the command surface (kernel,
platforms, core, daemon) imports src/commands/.
Generalizes the former guard (daemon + platforms).
R3 platforms-seam — platforms/ is statically imported only at the
core interactor seam (src/core/interactors/) and by
the daemon server; elsewhere use a dynamic import()
or a type-only import, preserving CLI cold-start.
Dynamic import('../platforms/*') and `import type` stay allowed.
Fixes the three pre-existing R3 violations by converting static
platforms value imports to dynamic imports (all in already-async call
sites, behavior-preserving and cold-start-improving):
- src/client/client.ts debug.symbols → lazy symbolicateCrashArtifact
- src/cli/commands/web.ts setup/doctor → lazy agent-browser-tool
- src/core/dispatch-interactions.ts runner-sequence → lazy (matches the
file's own dynamic-import pattern)
Wire the check into the Layering Guard CI job and add a check:layering
package.json script (also folded into check:tooling). scripts/layering/**
is excluded from fallow (untested CI script, like scripts/perf/**).
Replaces the manual "run with --debug, hand-count the runner phases" check with
an automated, committed assertion so the Phase 3 step (c) runner relocation (and
future runner refactors) can prove byte-identical runner request behavior.
- src/daemon/runner-request-count.ts: pure, unit-testable counter. Parses the
daemon --debug diagnostics ndjson and counts the iOS-runner round-trip phases,
plus baseline parse/compare logic. Owns RUNNER_ROUND_TRIP_PHASES as the single
source of truth, now imported by request-router.ts (was a local const) so the
in-process cost graft and the external counter never drift.
- src/daemon/__tests__/runner-request-count.test.ts: 13 unit tests over synthetic
ndjson fixtures (tolerant parse, counting, baseline parse/compare). Run in the
normal unit suite; no hardware.
- scripts/runner-request-count/: assertion harness (run.ts) + committed baseline
(expected-counts.json). Drives the existing smoke-ios replay scenario with
--debug in an isolated --state-dir, counts runner round-trips from daemon.log,
and asserts against the baseline. --update regenerates the baseline. Infra
hiccups are inconclusive (don't fail); only a real count drift fails.
- .github/workflows/ios.yml: new "Assert iOS runner request count" step in the
smoke-ios job, reusing the booted simulator.
- package.json: `validate:runner-count` script. .fallowrc.json: harness entry.
The baseline ships unarmed (established=false); the harness records observed
counts (printed + uploaded as a test/artifacts artifact) without failing, so the
maintainer arms it once from a real CI run.
* perf: reuse Apple runner cache across version bumps
* perf: remove unused Apple runner symbols
* perf: keep Swift runner unit tests out of runtime builds
* perf: skip Apple runner asset catalog in runtime builds
* perf: use concrete simulator for xcuitest script builds
* perf: show cold Apple runner startup progress
* perf: prewarm Apple runner cache during simulator boot
* refactor: dedupe Apple runner option plumbing
Add a Lint & Format job (oxlint --deny-warnings + new format:check script) and
a warn-only guard that flags imports of src/commands/* from src/daemon and
src/platforms; the guard flips to a hard failure once shared contracts move
out of the commands layer.
https://claude.ai/code/session_01LXZXzxi55sZ11DSyqWyBA2
Co-authored-by: Claude <noreply@anthropic.com>
* feat: add e2e command perf benchmark harness + nightly CI
Adds scripts/perf, a cheap end-to-end perf benchmark that drives the built
CLI through an ordered Settings tour of ~24 commands for N rounds, on a fully
isolated daemon/state-dir and self-cleaning device, and emits JSON + Markdown
reports. Per-command timing comes from wrapping each batchable command in its
own single-step batch (daemon durationMs) plus wall-clock around the process.
Wires a scheduled + workflow_dispatch CI job (perf-nightly.yml) that reuses the
cached iOS XCUITest runner (setup-apple-replay) and the Android replay host, and
runs the CLI from source via --experimental-strip-types (no dist build).
* refactor(perf): drive the harness CLI via runCmdSync, not spawnSync
Review (P2): repo rule is to spawn processes through src/utils/exec.ts, not
node:child_process directly. Switch the perf harness's invokeCli to runCmdSync
(allowFailure so non-zero exits are recorded as samples) and add a maxBuffer
option to ExecOptions/runCmdSync (snapshot payloads exceed Node's ~1MB default).
* perf(harness): warm the runner after open so the first measured command is clean
The first interaction after open/relaunch pays the one-time iOS XCUITest runner
startup (~10s+ cold) and a per-relaunch first-AX-query settle cost (~4s). That was
landing on the first measured command each round (snapshot -i), inflating it ~10x
vs the next snapshot. Run an untimed warmup snapshot -i after establishSession, after
each round's reset-open, and after every freshRoot relaunch, so no measured command
absorbs runner startup. Noted in the report header.
* refactor(perf): address review + fix Fallow CI
- exec.ts: extract spawnRejectionError + commandCloseFailure helpers, deduping the
error/close handler clones (Fallow duplication ✗ that surfaced once the maxBuffer
change pulled exec.ts into the audit scope).
- .fallowrc: exclude scripts/perf/** (non-shipped benchmark tooling, like examples/
test-app) so its naturally-moderate functions don't trip the complexity gate.
- config.ts: drop unused exports CLI_BIN/DEFAULT_OUT_DIR; add readIntValue so
--n/--rounds/--warmup report the actual flag + reject non-integers clearly.
- harness.ts: extract toSample(); type sampleError param as CliResult.
- scenario.ts: ScenarioStep is now a discriminated union on execMode (removes step.step!/
step.args ?? []).
- comment/legend rewords (platform defaults are local-convenience/CI-overridden;
elements = node count). check:fallow now green; typecheck/lint/unit pass.
* perf(harness): downgrade sample ok when a batch step reports ok:false
Defensive belt-and-suspenders for the Codex review note: stop-only batch already
surfaces a failed step as a top-level failure (caught by invokeCli), but if an
on-error=continue mode ever keeps the batch ok while a step fails, don't silently
count that step as a successful sample — derive ok from the step's own result.ok.