18 Commits

Author SHA1 Message Date
Michał Pierzchała e3880f10c8 fix(android-helper): build with the pinned build-tools version, fail on CI when unset (#2568)
build-android-helper.sh compiled with whichever build-tools directory was newest on the
image while every lane that builds a helper installs exactly build-tools;36.0.0. That
selection feeds d8 and aapt2, so a newer package on the runner changed the helper's
bytecode and resources rather than just its packaging, and nothing said so.

The script now takes the version as an input, its last positional or
AGENT_DEVICE_ANDROID_BUILD_TOOLS, resolves it under $SDK_ROOT/build-tools, and checks the
four tools the build actually runs instead of aapt2 alone. An unpinned build is a hard
failure on CI; locally the newest-installed fallback stays and is announced on stderr.

Each lane that builds a helper declares the version it installs and interpolates that same
value into its sdkmanager line and the build environment, so an install and a build inside
one lane cannot drift. setup-android-replay-host exports it to the packaging gate and names
it in both helper cache keys, which are keyed on APK bytes; size.yml and
release-android-snapshot-helper.yml declare it at job level. The fixture-repack cluster
keeps its own pin: a different command family that ships no helper APK.

Closes #2527
2026-09-14 07:35:00 +02:00
Michał Pierzchała b2b084d2e1 docs: fix phantom specifiers, the duplicate ADR 0019, and the Node floor (#2533)
AGENTS.md routed request cancellation/progress and diagnostics to
`@agent-device/capture-kit` subpaths that no package exports; both live in
`@agent-device/host-kit/request` and `@agent-device/host-kit/diagnostics`. It also
named `@agent-device/contracts` as an importable seam although that package
publishes no root export, and claimed `src/daemon/handlers/session.ts` was over
budget after that extraction already landed at 242 lines.

Two ADRs carried number 0019. The hop trace has its own claims to make, so it now
numbers 0023, joins the index, and keeps the links from ADR 0019 and ADR 0022.

The Node floor split was undocumented: `engines.node` stays at 22.12 because CI
installs the published tarball on that floor, while contributors need 22.13 for the
pinned pnpm. CONTRIBUTING now says so, and installation.md names the 22.12 floor and
the web backend's Node 24 requirement.

Extend the agent-guidance contract to resolve every `@agent-device/*` specifier
AGENTS.md names against the owning package's `exports`, root included, so neither a
phantom subpath nor a phantom package root can route an agent to a module that does
not exist.
2026-09-13 10:15:57 +02:00
Michał Pierzchała 608bf7aa47 Harden the MCP surface: registry rug-pull fix, operator-only credentials/endpoints, device-shell argv gate, declared timeouts (#2023)
* chore(release): keep the version on main distinct from every published version

Registry scanners diff the repository's tool surface per version string, so a
released number left on main while main keeps changing is indistinguishable
from a republished ("rug-pull") version — two scans of the same version see
two different tool sets (AS-012).

- release:publish now runs release:mark-dev after npm publish, moving
  package.json and the synchronized server.json to the next patch with a
  -dev prerelease marker.
- release:prepare refuses to publish while the -dev marker is in place, so
  a forgotten version bump cannot ship a prerelease as latest.
- Mark the current tree 0.20.11-dev: main had been sitting on the published
  0.20.10 while the tool surface kept changing, which is the live finding.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wb8EuoySL26UsdtCtRj5X8

* fix(mcp): remove credential inputs from the model-writable tool surface

Every MCP tool advertised daemonAuthToken (and the Metro tools bearerToken)
as a free-form string the model writes. The model both reads untrusted app UI
text and picks tool arguments, so on-screen text steering it to set a token
was a prompt-injection exfiltration path. Credentials are operator-owned:

- the keys are omitted from every advertised tool schema (MCP and AI SDK,
  which share listCommandTools()),
- an explicit value is refused with env-var guidance instead of being
  forwarded (the retired-field posture: refuse, never silently drop),
- operator-sourced values are untouched — env/config defaults still merge,
  and the daemon and Metro clients keep their AGENT_DEVICE_DAEMON_AUTH_TOKEN
  / AGENT_DEVICE_METRO_BEARER_TOKEN fallbacks. CLI flags are unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wb8EuoySL26UsdtCtRj5X8

* fix(mcp): move operator endpoints and paths off the model-writable surface; declare timeouts

Follow-up to the credential removal: daemonBaseUrl and the Metro
proxyBaseUrl are the endpoints the env-resolved tokens are SENT to, so a
model-writable value redirects the operator's token to an arbitrary server —
same exfiltration path, one step removed. stateDir, cwd,
iosSimulatorDeviceSet, and the three iosXctest* paths select operator
infrastructure, never per-call work. All of them leave the advertised
MCP/AI-SDK tool schemas and are refused as explicit input with env/config
guidance; operator env/config defaults keep flowing exactly as before
(config-backed defaults still merge, and explicit input can no longer
override them). Dropping these shared properties also cuts tools/list
substantially.

Every tool description now also declares its enforced client timeout
envelope (90s default, 180s install, unbounded only for the streaming test
runner), sourced from the descriptor registry's timeout policy so the
declared number cannot drift from the enforced one (answers AS-011, which
read the undeclared envelope as "no timeout").

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wb8EuoySL26UsdtCtRj5X8

* feat(ci): inventory every dynamic value reaching a device shell

adb shell, adb exec-out, and hdc shell join their argv into one string the
device's sh evaluates, so any unquoted dynamic element is a potential argv
injection — the class of bug the audit found (and fixed) on input text and
cmd clipboard set text. Nothing enumerated the surface, so a new call site
could regress it silently.

scripts/shell-argv is an AST-based gate (oxc-parser, same as di-seams and
layering) keeping an exact inventory of every dynamic device-shell argv
element, keyed by (file, expression) with counts: 121 values today. A new
or grown entry fails CI until the author quotes it through shellQuoteIfNeeded
or records it with --update in the same PR, making "a new value now reaches
the device shell" a reviewable diff; a stale entry fails the other way so
the inventory always matches the code. Wired as the shell-argv gate in the
lint lane and registered in the check catalog.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wb8EuoySL26UsdtCtRj5X8

* fix(shell-argv): satisfy the fallow audit without suppressions

The Compatibility & Provenance lane's fallow audit flagged the new gate:
main was an unused export (only the self-run guard consumed it) and four
functions sat over the complexity thresholds. Restructure instead of
suppressing: the AST walk dispatches through a composite-child-field table,
the argv detection is hoisted out of the visitor, drift reporting moves into
helpers, and main is no longer exported. Behavior is unchanged — the model
tests pass as written and the regenerated inventory is byte-identical
(121 values).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wb8EuoySL26UsdtCtRj5X8

* fix(test): close the port-reuse race in the unreachable-takeover test

Coverage (1) failed once in CI with the takeover notice missing while the
response still came from the fresh daemon — the exact signature of the
fresh fixture being handed the just-freed ephemeral port: the recorded
daemon becomes reachable and reusable (same version and signature), so the
takeover path is skipped. Bind the fresh fixture before acquiring and
freeing the unreachable port; with no bind after the close, the port can
never be reclaimed. Line-neutral so the size-ratchet pin holds.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wb8EuoySL26UsdtCtRj5X8

* fix(mcp): enforce the advertised tool schema at a real admission boundary

P1 (reported by the PR author): hiding operator keys from tools/list did not
stop them reaching the command route. The router forwards raw tools/call
arguments verbatim and resolveMcpConfigDefaults reads them as CLI flags, so an
unadvertised `config`/`remoteConfig` key loaded an arbitrary file whose
daemonBaseUrl/daemonAuthToken then flowed to runCommand — a model-writable
redirect to an attacker endpoint with the operator's token. Reproduced:
{config: <path>} on `snapshot` put both values into the command input.

Replace the per-key operator refusal with a deny-by-default admission boundary
in the shared executor (the one path both the MCP router and the AI SDK adapter
use): every raw input key must appear in the tool's advertised schema, else it
is rejected with guidance BEFORE config/env resolution. This closes the config
loaders, the operator keys, and any unknown key at once, and makes the
advertised additionalProperties:false contract actually enforced. Operator
env/config defaults still resolve — they never arrive as tool input.

Retired keys (maxSize) are admitted so the command's own reader still answers
with migration guidance; they're exposed as metadata.retiredInputKeys for that.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wb8EuoySL26UsdtCtRj5X8

* revert(ci): remove the check:shell-argv inventory gate

The PR author correctly flagged that this gate is an inventory, not a
security invariant: --update lets any site self-approve a raw value, and the
literal-first array heuristic is blind to indirect argv (a variable-built
subcommand, or an argv assembled in a helper). Reproduced: adb(['shell',
'input','text',text]) is inventoried, but const s='shell';
adb([s,'input','text',text]) yields no finding. Shipping it security-framed
gives false assurance.

Remove it. The sound fix — a typed device-shell execution boundary where a
raw string cannot reach adb/hdc shell without being quoted or explicitly
marked — is a ~188-site cross-package migration on device execution paths,
scoped to a dedicated follow-up PR. The two known-dangerous sites (input
text, cmd clipboard set text) already quote through shellQuoteIfNeeded on
main, so no regression. This keeps the PR focused on the MCP tool surface.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wb8EuoySL26UsdtCtRj5X8

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-25 15:19:07 +02:00
Michał Pierzchała 05a1d76f2e test: add daemon RPC wire-surface compatibility gate (#1717)
* test: gate daemon RPC wire compatibility against the last released tag (#1432)

ADR 0006 fixes exactly when DAEMON_RPC_PROTOCOL_VERSION must be bumped, and
nothing checked that it was. The runtime guard (readRemoteDaemonHealth) refuses
a mismatched peer, but only fires when someone remembered the bump — a wire
change that skipped it left both sides advertising protocol 2 while parsing
different payloads, which is the failure ADR 0006 exists to prevent.

Local daemons cannot skew (isReusableDaemonInfo takes over on any package
version mismatch). Cross-machine is skewed by design — proxy, cloud/limrun, a
remote macOS host — and ADR 0006 explicitly rules package version out as the
compatibility gate there, so the one boundary where skew is intended was the
one boundary with no gate.

test/wire-compat/surface.ts declares the wire surface grouped by the ADR bullet
each group serves, quoting it, with an `uncovered` note where a bullet is only
partly digestible (the /health and /rpc literals inside http-server.ts stay
reviewer-owned: a moved route 404s at connect time rather than misparsing).
ledger.json records what each declaration hashes to, at which protocol version.

Two gates, split for the same reason the replay-compat corpus splits:
- unit-core holds the ledger to its source and prints the digest to paste;
- Released-Surface Compatibility reads the ledger at the last RELEASED tag and
  requires the drift since then to carry a bump or a compatibleChanges ack.

From one commit a bumped ledger and an unbumped one are both just an edited
file, so only a released baseline can tell them apart. Acks are keyed by the
digest they cover, so one "added an optional field" cannot launder later
changes. Digests ignore comments and formatting; the manifest's closure is
derived from the AST, so a field typed by an unlisted sibling fails rather than
sitting outside the gate.

CI cost: one added job (checkout + toolchain + two node scripts, ~1 min),
mirroring the existing full-history replay-compat job.

* test: close wire-surface overclaim and make the closure fail closed (#1432)

Addresses both review P1s on #1717.

P1 — the manifest materially overclaimed ADR 0006 coverage. It quoted all four
bullets while digesting only the payload TYPES, so the producer and consumer
seams could break a skewed peer without moving a listed digest. Now listed on
both sides of every boundary: JSON-RPC method sets and the projections that
turn each method's params into a DaemonRequest, createRpcError/sendJson/
writeRpcResponseEnvelope, resolveToken and the auth-hook types, upload
preflight/finalize/308 handlers and the resumable ticket shape, artifact route
and download/inventory framing, REST error mapping, and the client's own
payload builder, lease-method mapping, response parser and error projection.
57 -> 117 declarations.

What stays out is now named rather than implied: createDaemonHttpServer's
dispatch wiring and the /health and /rpc literals inside it. Everything it
dispatches WITH is digested individually, and a moved route 404s at connect
time rather than misparsing — the loud failure, not the silent one.

P1 — imported and re-exported payload shapes escaped the closure.
declarationHomes() scanned only the manifest's own files and the walk
continued silently when a name could not be placed, so a listed type could
gain foo?: ImportedShape from a new module and stay green. Resolution is now
explicit and fails closed: relative imports, workspace specifiers (through the
owning package's own exports map, so a re-pointed export cannot drop a type),
and facade re-export chains. Every referenced name must land on a listed
declaration, a waiver with a written reason, a declared external module, or the
TS/Node global set. Fixed two extractor blind spots the walk exposed: a
declaration's own generic parameters and `as const` were being reported as
references.

Planted-red proofs (wire-mutations.test.ts): 13 cases independently mutate
method naming, response serialization, response parsing, auth projection,
upload ticket shape, 308 framing, artifact framing, REST error mapping, and
progress framing, each asserting the digest moves; 3 probes prove the closure
really reaches across a package boundary, a facade re-export, and a plain
relative import. Mutations apply inside the declaration's own span — a
whole-file replace silently hit a sibling sharing the substring, which is how
the first draft of one case passed vacuously.

The largest waiver pair (InternalRequestOptions, CommandFlags) rests on ADR
0006's own additive rule: they reach the peer inside DaemonRequest's untyped
flags/input bags, and the decision says a new flag needs no bump. Digesting
them would fire the gate on every new CLI flag and train reviewers to
rubber-stamp acks.

* test: list the consumer half of the auxiliary HTTP boundaries (#1432)

Addresses the remaining review P1 on #1717. The manifest claimed both sides of
response/upload/artifact framing while listing nothing from upload-client.ts,
daemon-artifacts.ts, or the health consumer in daemon-client-transport.ts, so
those parsers could narrow without moving a listed digest or protocol 2.

Now listed (117 -> 141 declarations):

- /health consumer: RemoteDaemonHealth, readHealthPayload, readDaemonHttpHealth,
  readRemoteDaemonHealth. This is the sharpest of the three — narrowing the
  reader or the comparison disables the very refusal ADR 0006 exists to
  guarantee, and nothing else in the repo would notice.
- /upload consumer: UploadResponse, UploadPreflightResponse, UploadPreflightResult,
  parseUploadPreflightResult, requestUploadPreflight, uploadDirectArtifact,
  tryDirectUploadWithResume, shouldRetryDirectUpload, finalizeDirectUpload,
  uploadLegacyArtifact, ARTIFACT_HASH_ALGORITHM, isStringRecord, and
  PreparedUploadArtifact — whose sha256/sizeBytes/fileName/artifactType/
  contentType fields ARE the preflight body the daemon parses.
- /artifacts/* consumer: DaemonArtifactEndpoint, buildDaemonArtifactUrl,
  isRemoteDaemon, DownloadRemoteArtifactParams, downloadRemoteArtifact,
  materializeRemoteArtifacts, resolveMaterializedArtifactPath.

Running the closure fail-closed over the new files surfaced three more stops,
each decided rather than skipped: PreparedUploadArtifact listed (it is payload),
UploadProgressSink waived (client-local rendering, never leaves the process),
and src/daemon/types.ts#DaemonArtifact waived as a re-export alias of the listed
kernel type, matching its DaemonRequest/DaemonResponse siblings.

10 more planted-red mutations cover the new seams: health version-read and
mismatch-refusal defeated, RemoteDaemonHealth field dropped, preflight parser
narrowed, preflight/legacy response shapes narrowed, finalize body key renamed,
ticket field renamed, artifact tenant header dropped, artifact URL moved. A
fourth closure probe proves the upload-consumer files are genuinely reached by
the walk rather than merely listed. 22 -> 33 tests.

The README now states the coverage as a producer/consumer table per boundary,
so the claim is checkable at a glance instead of asserted in prose.

* test: list the client half of the resumable 308 contract (#1432)

Addresses the third review P1 on #1717. Listing the daemon's
handleResumableUpload proved it still PRODUCES 308; nothing proved the client
still CONSUMES the released one. src/remote/upload-stream.ts owns that half and
was entirely outside the manifest, so a newer client could stop accepting
`upload-offset`, change how it reads `Range: bytes=0-N`, or emit a different
resumed `Content-Range` without moving one of the 141 listed digests.

Now listed (141 -> 151): UploadStreamResponse, streamFileToHttpRequest,
streamFileToHttpRequestAttempt, buildUploadRequestHeaders, isUploadResumeStatus,
isUploadRedirectStatus, parseUploadResumeOffset, parseNonNegativeIntegerHeader,
firstHeaderValue, MAX_UPLOAD_REDIRECTS.

streamFileToHttpRequestAttempt is listed despite its size, unlike
createDaemonHttpServer which stays in `uncovered`. The distinction is stated at
the declaration: the HTTP server only dispatches to handlers that are each
digested, while the attempt loop IS the resume state machine — it decides
whether a 308 continues the upload and what the next request carries, so its
sequencing alone can break a released daemon while every helper keeps its digest.

6 new planted-red mutations prove the client half moves the ledger: a dropped
`upload-offset` fallback, narrowed Range parsing, a changed resumed
Content-Range, 308 no longer treated as continue, a narrowed UploadStreamResponse,
and dropped header-value coercion. 33 -> 39 tests.

Closure fail-closed surfaced two more stops: UploadStreamProgressOptions waived
(local byte-progress rendering) and URL/URLSearchParams added to the global set.

README now carries a `/upload` resume row in the producer/consumer table, and
names the pattern behind three rounds of review: the coverage sentence kept
getting written ahead of the coverage, so the table and the `uncovered` notes
are the claims to trust — they are checkable against surface.ts, prose is not.

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-10 20:52:29 +02:00
Michał Pierzchała adcbdda8f0 perf: speed up unit tests and streamline checks (#1488)
* perf: speed up unit tests and streamline checks

* fix: validate canonical packaging workflows
2026-07-29 18:53:10 +02:00
devin-ai-integration[bot] edca35d122 chore(deps): Renovate config, packageManager-derived pnpm in CI, repo-wide format (#1444)
* chore(deps): add Renovate config and enforce packageManager pnpm version in CI

Refs #1422

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* chore: bump pnpm to 11.17.0 and format the whole repo with oxfmt

format/format:check drop their hand-maintained path list: oxfmt already skips
node_modules and honors .gitignore, so the only exclusion list is
.oxfmtrc.json ignorePatterns.

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test(mutation): accept either quote style in the affected-lane path filter

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* chore(deps): keep fixture-app runtime deps as individual Renovate PRs

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>
2026-07-28 13:50:39 +02:00
Michał Pierzchała 56b72c5cf7 refactor(boundaries): put shared contracts below their consumers, gate the result (#1405)
* refactor(boundaries): move shared contracts below their consumers

Acts on the depgraph findings: type-only edges are invisible to R5, so
vocabulary that everything depends on had drifted above the zones that use it.

- contracts/: the four platform-plugin facet tags (LogBackend,
  RecordingBackendTag, PerfMetricsSamplerTag, PlatformGatedProviderResolverKey)
  now live beside the plugin contract itself, which also moves out of core/;
  NetworkEntry moves next to the command surface that renders it; and the
  click-button, recording-export-quality, interactor-types and
  runner-lease-context vocabularies move down out of core/.
- (root) drops from 29 files to 13: the internal *-contract/output/annotation
  modules move into contracts/, kernel/ (daemon-error, observability-redaction
  beside kernel/redaction), core/ (batch-policy, an ADR 0008 projection),
  commands/ (cli-command-aliases) and remote/ (upload-progress, upload-stream).
  What remains is entrypoints and the composition roots that R2 requires to
  sit outside the spine.
- utils/ joins the ranked spine at rank 1 after its only two upward files move
  to the zones they were reaching for (cli/resolve-cli-options,
  cli-schema/cli-config), putting ~336 value edges under the gate.
- Internal imports that routed types through the client-types re-export hub now
  name their real source.

Type-only spine inversions drop from 61 to 35; the remainder is two clusters
(client/client-types.ts and the ADR 0003 daemon facet). No behaviour change:
4470 unit tests and the layering gate pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bfu8HofkhybiAm5LECfqur

* style: merge the duplicate contract imports the tag moves created

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bfu8HofkhybiAm5LECfqur

* refactor(imports): name the declaring module, share find's argument rules

Two follow-ups from re-measuring the graph after the boundary moves.

1. 89 type imports across 79 files routed through a re-export hub in another
   zone: `CliFlags` reached through commands/cli-grammar/flag-types.ts (52) when
   it is declared in contracts/cli-flags.ts, the replay suite result types
   reached through daemon/types.ts when they are declared in contracts/replay.ts,
   the doctor types through a daemon handler module, and so on. Each hop invented
   a cross-zone edge the architecture never asked for — including every apparent
   replay -> daemon and utils -> commands dependency. They now name the module
   that declares them. Within-zone hops are left alone; those are a local style
   choice, not a boundary claim.

2. `find`'s three positional/flag checks existed in both daemon entry points with
   hand-repeated messages, and the copy in dispatchFindReadOnlyViaRuntime was
   unreachable — its only caller validates first. Both now call checkFindArgs in
   selectors/find.ts, beside parseFindArgs and isReadOnlyFindAction, for the
   reason that module's own comment already gives: so the two paths cannot
   disagree. The refusal is returned rather than thrown, because the two
   mechanisms are not observationally identical in the session event log.

Type-only spine inversions: 61 -> 35. 4470 unit tests and every gate pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bfu8HofkhybiAm5LECfqur

* feat(layering): ratchet type-only spine inversions (R6)

R5 ignores type-only edges by design — they cost nothing at runtime and do not
affect cold start — so nothing was watching the direction they point. Ranking
them the same way found 61 inversions, including contracts/ and utils/ declared
in terms of rank-4 zones. 26 are fixed by the preceding commits; R6 pins the
rest per zone pair so they can only shrink, and a new pair fails outright rather
than being added to the baseline.

The two remaining clusters each need their own change, and the baseline says so:
the per-command Options/Result vocabulary declared inside the public Node-client
surface, and the ADR 0003 daemon facet shape that core's descriptor registry
composes.

Both ratchet directions are covered: growth fails, and shrinking without
lowering the number fails too, so the baseline cannot quietly stop describing
the tree.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bfu8HofkhybiAm5LECfqur

* docs: record the import-graph findings behind this refactor

A dated snapshot, not a normative document: when it disagrees with
scripts/layering/, the gate wins. The graph tool that produced it lives on the
claude/depgraph-viewer branch, deliberately out of this change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bfu8HofkhybiAm5LECfqur

* refactor(selectors): state the shared selector argument rules once

R2 (commands-floor) forbids the daemon from importing commands/, and that is the
right call: commands/ is the client-side surface — its only consumers are cli/,
cli-schema/, mcp/, client/ and the composition roots — while the daemon is the
executor on the other side of the wire. ADR 0008 protects exactly that seam.
Relaxing R2 would let the executor depend on a client projection and pull CLI
grammar and output formatting into the daemon's bundle.

But the rule does force duplication: the daemon must validate independently
because it accepts requests from any client, so 10 refusal messages existed in
both zones. The only place a shared rule can live is below both, and selectors/
already held the parsers (splitIsSelectorArgs, splitSelectorFromArgs,
isSupportedPredicate) and even the `is` predicate message — just not the checks
that use them.

Three drifts had already appeared in the `is` predicate rule alone:

- commands/interaction/selectors.ts re-implemented the predicate list as an
  inlined seven-way `!==` chain while importing the message and hint from
  selectors/predicates.ts, so adding a predicate to the shared list would not
  have reached the CLI grammar.
- That inlined chain compared the raw token, so the CLI rejected `is TEXT ...`
  while the daemon it hands the command to accepts it. The CLI now matches the
  executor; this is an intentional alignment, not an accident.
- isCommand raised the same refusal without IS_PREDICATE_USAGE_HINT, so whether
  an agent got recovery guidance depended on which layer noticed first — the
  failure mode ADR 0010's audit calls out.

checkIsPredicate, checkIsArgs, checkGetFormat, checkElementTargetArgs and
checkWaitText now hold those rules, each beside the parser it wraps, and report
a refusal rather than choosing how to raise it: the daemon returns a response,
the command surface throws. Those mechanisms are not interchangeable — they
write different session events — so the shared check stays out of that decision.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bfu8HofkhybiAm5LECfqur

* feat(daemon): give ADR 0014's ref frame one transition, pin SessionState owners

`SessionStore.get()` returns the live record out of a private Map and `set()`
re-puts the same reference, so every `session.<field> = …` in the daemon is a
durable write to store-owned state: 57 of them across 17 files, against 26
`set()` calls that are therefore ceremonial. Nothing at the store boundary can
check what those writes are supposed to keep true.

Measuring which module writes which field showed the problem is narrower than
the raw count suggests — 16 of 27 fields already have exactly one writer. The
sharp case is ADR 0014's ref frame: `refFrameState`, `refFrameScope`,
`refFrameTree` and `refFrameGeneration` must move together or the frame is
incoherent (an `active` state with a stale tree resolves refs against a
namespace nobody authorized), yet complete issuance wrote them in ref-frame.ts
and partial issuance wrote the same four in session-snapshot.ts. ref-frame.ts's
own header claims to be "the single owner of the frame's transitions", and
session-snapshot.ts documented itself as the exception. Both forms now go
through `activateRefFrame`; they differ only in scope.

`recordSession` deliberately moves alone in two paths (recording without arming
a publication), so the save-script cluster gets no invented abstraction — it
gets ownership instead. R7 records every field's owner and stops the set from
growing quietly: a new SessionState field must declare one, a foreign write
fails naming the owner to call, and an owner that stops writing must be removed
so the table cannot drift into fiction. Field names are read out of the
`SessionState` declaration, so a daemon module with an unrelated local named
`session` — a provider or runner session — cannot trip it.

4475 unit tests and every gate pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bfu8HofkhybiAm5LECfqur

* docs: record the reference semantics and refresh the findings

SessionStore.get/set now document that the record is handed out live, since that
is the fact behind R7. The findings snapshot picks up the resolved R2 question,
the ref-frame consolidation and the two new gate scopes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bfu8HofkhybiAm5LECfqur

* refactor(boundaries): rank every satellite zone, extract the provider port

Second-order effect of the earlier rounds. With `utils` on the spine and
`(root)` emptied of shared contracts, the eleven zones that were unranked
"because ranking them would invent an order the architecture had not committed
to" turned out to have a consistent rank already — the order was there,
unasserted. Solving the constraint system showed one blocker: `utils/remote-config.ts`
projected a remote-config profile into `CliFlags` while reaching up into
`remote/`, and its only three consumers were in `cli/`. It moves there as
`cli/remote-config-flags.ts`, and every satellite zone joins the spine.

Ranked coverage goes from 730/895 files to 882/895. Only `(root)` stays out, and
now for one stated reason: R2 forbids `daemon/` from importing `commands/`, so
the files that wire them compose the spine from above.

Ranking them exposed 22 type-only inversions R6 had never been able to see, and
they were concentrated rather than scattered:

- The device-provider port. `providers/` and `cloud-webdriver/` implement what
  the daemon calls, so both sides name `DeviceLease`, `LeaseLifecycleProvider`,
  `LeaseLifecycleContext` and `DeviceInventoryProvider` — now declared in
  contracts/device-provider.ts, below both. The adapters also imported the
  daemon's NARROWED `DaemonRequest` while only ever reading `req.flags`; they now
  name the public one from kernel/contracts.
- `MetroPrepareKind` and the remote-config profile field groups move to
  contracts/ for the same reason: the command surface validates them and
  contracts/cli-flags.ts is composed from them.

Two clusters remain, ratcheted with their reasons in TYPE_INVERSION_BASELINE:
the client-types vocabulary, and `SessionAction`, which needs `CommandFlags` and
`DaemonBatchStep` to move with it.

Also fixes two things CI caught: the eight type re-exports my earlier import
redirection orphaned (none published through any src/sdk/* entrypoint, so no
public surface changes) and `isSupportedPredicate`, now module-private since
`checkIsPredicate` is the admission API. `fallow-baselines/health.json` is keyed
by path, so the moved cli-config entry moves with the file rather than being
regenerated.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bfu8HofkhybiAm5LECfqur

* fix(selectors): use the admitted predicate, not the raw option

Review finding. `isCommand` called `checkIsPredicate` and then kept reading
`options.predicate` for the capture policy, the `exists` branch,
`evaluateIsPredicate`, the failure message and the returned result. Admission
normalizes case, so an upper-case predicate was let past the gate and then
evaluated against lower-case branches: `EXISTS` skipped its own branch and fell
through to the generic path, and the result echoed the raw token. I widened
admission at that surface without threading the normalized value through it —
the CLI-grammar surface in the same change does use the admitted value.

Every decision after admission now reads it.

Two tests, both verified to fail without the fix:

- a production-route regression driving `device.selectors.is` with
  `EXISTS`/`TEXT`, plus one pinning that an unknown predicate is still refused
  WITH the ADR 0010 usage hint;
- a surface parity gate (selectors/__tests__/is-argument-surface-parity.test.ts)
  in the repo's existing parity style, asserting the daemon and CLI-grammar
  surfaces reach the same verdict and hand the same normalized predicate
  downstream across an input table. A helper-only test cannot catch a surface
  that admits correctly and then discards the result, which is what happened
  here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bfu8HofkhybiAm5LECfqur

* docs: name the pre-push gate, and the formatter's path allowlist

Both misses in this PR's review were process, not judgement, and the docs
pointed the wrong way for both.

AGENTS.md said "prefer the aggregate package.json scripts" without naming which
aggregate, and CONTRIBUTING listed `pnpm test` and the targeted checks but never
`pnpm check`. `check:tooling` looks like the gate and is a subset of it: it stops
before the Fallow audit, so the dead exports this PR introduced passed a clean
`check:tooling` and failed CI. Both files now name `pnpm check`, say what it
covers, and say what it cannot (the device matrix).

The same gap produced a second mistake twice: `oxfmt <path>` reformats whatever
you point it at, while the repo's `format` script is an allowlist that excludes
`scripts/` and every `.md`. One run reformatted 50 unrelated script files into a
commit; the next nearly did it to AGENTS.md. AGENTS.md now says to run
`pnpm format`, never `oxfmt <path>`.

It also records the rule that cost a CI cycle: Fallow's baselines are keyed by
path, so a renamed file needs its baseline entry moved, not the baselines
regenerated.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bfu8HofkhybiAm5LECfqur

* revert: undo stray formatter output across docs and scripts

Three separate `oxfmt <path>` runs in this branch reformatted files the repo's
`format` script deliberately excludes: 55 files under scripts/maestro-conformance
plus scripts/perf, sync-mcp-metadata and the slow-test reporter, and 12 markdown
files including six ADRs and docs/agents/. All of it was whitespace, quote style
and markdown table padding — no content — but it inflated the diff a reviewer has
to read and would have rewritten prose ownership across files this change has no
business touching.

All 70 are back to their origin/main content, so the diff outside src/ is now
exactly this change's scope: three docs, scripts/layering, the Fallow baseline,
and five provider integration tests.

The rule this violated is now in AGENTS.md: run `pnpm format`, never
`oxfmt <path>`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bfu8HofkhybiAm5LECfqur

* style: reformat two provider tests with the repo's pinned oxfmt

`pnpm format:check` failed in CI on the two files whose imports I merged by hand.
The repo pins oxfmt 0.42.0 as a devDependency and both `format` scripts invoke
`./node_modules/oxfmt/bin/oxfmt`; I had reformatted with `npx oxfmt`, which
resolved 0.60.0, and the two versions disagree about wrapping a 100-column import.

This is the rule AGENTS.md already states — run `pnpm format`, never oxfmt
directly — so there is nothing to add to the docs, only to do.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bfu8HofkhybiAm5LECfqur

* fix(ci): install deps for the layering guard, and gate the zero-dep contract

The Layering Guard job failed with ERR_MODULE_NOT_FOUND on `oxc-parser`. The job
ran with `install-deps: false` — no `pnpm install`, so no `node_modules` — and R7
had started parsing the daemon with oxc-parser instead of matching assignment
operators with a regex. `pnpm check:layering` passed on every local run, because
locally `node_modules` is always there.

The job now installs dependencies. The alternative was to put R7 back on a regex,
which cannot see `??=` or a computed `session[key] =` write, so it would trade a
correct rule for a fast job.

That leaves the interesting part: the zero-dep contract is real for the jobs that
keep it, and it is invisible to every local run, which is the worst combination a
constraint can have. R8 makes it checkable. It reads the zero-dep job list out of
`.github/workflows/` rather than restating it — declaring a job zero-dep is what
puts it under the rule — walks each job's entry scripts and their whole
relative-import closure, and requires every specifier to be a Node builtin or
another repo file. A zero-dep job whose entry scripts the scan cannot identify
fails too, so the rule cannot be escaped by changing how the job invokes them.

Specifiers come from oxc-parser's module record, not a line scan. The closures
include `--test` files, and a test about imports naturally embeds import syntax in
a fixture string; the line scanner reported two such phantom violations in
model.test.ts before the switch, which is how a gate stops being trusted.

Verified by re-running the real gate against three injected regressions: the
layering job back on `install-deps: false` (reproduces the exact CI failure,
pointing at session-state.ts:24), a package import added to the still-zero-dep
affected-selector closure, and a zero-dep job whose run step names no script.

Also corrects the CONTEXT.md spine paragraph, which still described the satellite
zones as deliberately unranked after they had all joined the ranked spine.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bfu8HofkhybiAm5LECfqur

* fix(layering): make R7 exhaustive, and follow session records through aliases

Review finding: `SESSION_STATE_FIELD_OWNERS` covered 27 of `SessionState`'s 42
fields and nothing asserted parity, so a new field could be added and pass the
gate by being invisible to it. R7's advertised claim — "every SessionState write
is inside its declared owner" — was broader than what it checked.

Investigating that turned up a second, larger gap the finding did not name: the
scan only recognized a binding literally named `session`. The daemon names these
records by role, so `nextSession`, `provisionalSession`, `completedSession`,
`preRunSession` and `preEntrySession` were all invisible — and three of those
writes were genuine violations R7 existed to catch:

  src/daemon/snapshot-runtime.ts:256  nextSession.snapshotScopeSource
  src/daemon/snapshot-runtime.ts:265  nextSession.snapshotGeneration
  src/daemon/handlers/session-replay-runtime.ts:707
                                      preEntrySession.pendingRecordAndHeal

The first two are the #1076 versioned-ref invariant: the generation advances
exactly when the stored tree is replaced. That rule lived in `setSessionSnapshot`
and had acquired a second statement of itself in snapshot-runtime.ts, whose own
comment admitted the bypass. It now goes through `setSnapshotLineage` in the
owning module. The third clears a watermark stamped by session-replay-resume.ts;
`clearPendingRecordAndHealWatermark` puts the clear beside the stamp.

Gate changes:
- Binding detection accepts aliases, paired with the existing declared-field
  filter so an unrelated `…Session` local only registers if it also writes a
  field SessionState owns — where the remedy is the same anyway.
- `fieldClassificationDrift` asserts parity in all three directions:
  unclassified, in-both, and naming a field SessionState no longer declares.
- `STORE_OWNED_SESSION_STATE_FIELDS` classifies the 11 fields the store
  establishes at construction. It is a positive claim, so a direct write to one
  fails and names both remedies.
- Four fields the widened scan made visible (`lease`, `deviceClaim`, `appName`,
  `saveScriptComplete`) got real owners.

`nextSnapshotGeneration` is now module-private: replacing its only external call
site orphaned the export, which `pnpm check` caught via Fallow.

Verified against three injected regressions: a new SessionState field with no
direct write (the reviewer's exact scenario), a foreign write through an alias
binding, and a direct write to a store-established field. All three rejected.
`pnpm check` green, 4486 unit tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bfu8HofkhybiAm5LECfqur

* docs(daemon): correct the snapshot-lineage claim, and pin the real contract

Device verification of the snapshot-lineage route found that a ref pinned before
a `diff` keeps resolving with no pinned-ref warning. That is the designed ADR
0014 behaviour, not a regression — the comment describing it was wrong, and I
propagated it.

`main`'s comment in snapshot-runtime.ts said a diff "leaves client refs pinned to
the previous generation, which is exactly what the pinned warning diagnoses". The
counter and the authorization epoch are different clocks:

  - `diff` passes `issuesRefsToClient: false`, so it never reactivates the frame;
  - `resolveRefStalenessWarning` compares a pin against the frame EPOCH, not the
    observation counter, and its own comment says why — a capture that bumped the
    counter must not make a valid pin from the issuing frame look stale.

So advancing the counter is not the same as invalidating client refs, and the
observable the comment promised does not exist. I carried the sentence into
`setSnapshotLineage`'s doc when the transition moved, and then into a hardware
verification request, which cost a reviewer a device run against a false claim.

`setSnapshotLineage` itself is unchanged and was a pure move: same expressions,
same inputs as the inline assignments it replaced, so this route behaves exactly
as it does on main.

A comment that contradicts the code should be an assertion instead, so the
contract is now pinned in session-snapshot.test.ts: the diff advances the counter,
preserves the epoch, leaves the pre-diff pin resolving without a warning, and
still warns for a pin from a different frame. Verified to fail when the epoch
comparison is swapped for the counter. A second test covers the keep-current
branch, which had no coverage.

`pnpm check` green, 4488 unit tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bfu8HofkhybiAm5LECfqur

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-27 08:08:34 +02:00
devin-ai-integration[bot] 8246362999 chore: baseline-free production-exports cleanup (#1276) (#1282)
* chore: baseline-free production-exports cleanup (#1276)

Classify and burn down the 32 baseline-tolerated unused production exports.

- Live seams: annotate with @internal JSDoc visibility tags (test hooks,
  introspection helpers, public install-source constant) so fallow no longer
  treats them as dead production exports.
- Wrappers: collapse re-export wrappers in commands/index.ts (ref/selector)
  and daemon/lease-context.ts (buildLeaseDiagnosticsContext); update all
  importers to pull directly from the source module.
- Stale baseline entry: remove the non-existent
  resetAndroidMultiTouchHelperInstallCache entry.
- Empty fallow-baselines/production-unused-exports.json so
  check:production-exports now fails loudly on any new dead export.

Fixes #1276

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* chore: address review feedback on production-exports cleanup (#1276)

- CONTRIBUTING.md: document that intentional non-production exports should use
  JSDoc @internal with a short justification, treated as a reviewed baseline entry.
- isPlatform: fix JSDoc tag to "@internal" and remove conflicting "public" wording.
- ARCHIVE_EXTENSIONS: re-export from src/sdk/install-source.ts so the public
  install-source subpath has a real consumer story for the constant.

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* chore: make production-exports check truly baseline-free (#1276)

- Drop --baseline from pnpm check:production-exports and remove the
check:production-exports:baseline generation script.
- Delete fallow-baselines/production-unused-exports.json.
- Update CONTRIBUTING.md to describe the baseline-free behavior and remove
references to reviewed baseline entries for production unused exports.

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>
2026-07-16 11:44:07 +02:00
Michał Pierzchała 6e21fedc08 refactor: remove production-unused exports (#1203) 2026-07-10 18:00:43 +02:00
Michał Pierzchała fb1117f229 ci: ratchet against production-unused exports (#1202)
* 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
2026-07-10 17:14:54 +02:00
Michał Pierzchała b1d77c68af docs: document conservative revisit triggers (#1056) 2026-07-03 12:11:42 +02:00
Michał Pierzchała 98c0b1d3bf test: migrate test app to expo dev client (#881)
* test: migrate test app to expo dev client

* docs: align test app device targeting

* docs: clarify dev client setup tradeoffs

* docs: remove stale sdk reference
2026-06-25 21:43:14 +02:00
Michał Pierzchała a0522c7bcc docs: document issue label workflow (#742) 2026-06-10 13:00:34 +02:00
Michał Pierzchała a7efcd468f chore(fallow): align local runs with the CI diff gate and upgrade to 2.91 (#735)
* chore(fallow): fit config to repo profile so baselines stay near-empty

- Raise health thresholds in .fallowrc.json to the smallest values that
  pass on a clean tree (maxCyclomatic 58, maxCognitive 77, maxCrap 591)
  instead of grandfathering ~180 findings in fallow-baselines/health.json.
- Raise duplicates.minTokens to 66, the smallest value covering the four
  tolerated clone groups (largest is 65 tokens).
- Regenerate baselines: health.json shrinks from ~18.6 KB of grandfathered
  finding counts to refactoring-target metadata only; dead-code.json is
  empty.
- Upgrade fallow 2.52.0 -> 2.91.0: 2.87.0 made ignorePatterns silence the
  "examples/test-app is not declared as a workspace" warning, which 2.52.0
  emitted regardless of config.
- Remove the unused ensureAdb export (and its now-unused imports) from
  src/platforms/android/adb.ts; it is not re-exported by any public entry
  and has no references anywhere in the repo.
- Document local (pnpm fallow) vs CI (fallow audit) usage in
  CONTRIBUTING.md.

https://claude.ai/code/session_01LXZXzxi55sZ11DSyqWyBA2

* chore(fallow): keep default thresholds, gate locally via diff-based audit

Revise the previous commit after review: pinning global thresholds at the
repo's historical maxima (cyclomatic 58, cognitive 77, CRAP 591, minTokens
66) weakened the gate for brand-new code and left zero headroom on the
worst existing functions. Restore the original design — fallow default
thresholds with legacy findings grandfathered per-file in
fallow-baselines/health.json — and fix the local-DX problem at the script
level instead:

- .fallowrc.json: drop the health/duplicates overrides so fallow defaults
  (cyclomatic 20, cognitive 15, CRAP 30, minTokens 50) apply to new code.
- fallow-baselines/health.json: regenerate at default thresholds under
  fallow 2.91 (201 grandfathered findings across 108 files); dead-code
  baseline stays empty.
- package.json: `pnpm fallow` now runs `fallow audit --base origin/main`,
  the same diff-based gate CI uses, so it passes on a clean tree. The old
  full-tree summary moves to `pnpm fallow:all` (expected to report legacy
  findings). `check:fallow` is unchanged (CI passes an explicit --base).
- CONTRIBUTING.md: correct the fallow docs accordingly.

Verified: clean tree passes; a new unused export fails the audit; a new
cyclomatic-25 function fails the audit; +1 branch growth in an already-
grandfathered function (classifyBootFailure) is absorbed by the baseline.

https://claude.ai/code/session_01LXZXzxi55sZ11DSyqWyBA2

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-10 10:53:04 +02:00
Michał Pierzchała 896adcc625 feat: add maestro replay compatibility (#561) 2026-05-19 20:49:34 +02:00
Michał Pierzchała d5cbec08fc [codex] Speed up Apple CI jobs (#305)
* perf: speed up apple ci jobs

* chore: address ci review feedback

* chore: unify apple runner build scripts
2026-03-27 17:51:13 +01:00
Michał Pierzchała e84612a393 feat: docs (#7)
* feat: docs v1

* update wording

* update action
2026-02-05 15:25:52 +01:00
Michał Pierzchała 4da4745a6c initial commit 2026-01-30 19:41:00 +01:00