Merge origin/main into /spec branch — retag v1.45.0.0 → v1.47.0.0

main moved to v1.46.0.0 (gstack v2 foundation, eval-first floor across
51 skills) while this branch was at v1.45.0.0. v1.46 also reserved
v1.45.0.0 for the design daemon feature. Retag this branch's release
v1.45.0.0 → v1.47.0.0 so it lands cleanly on top of main.

Conflict resolutions:
- VERSION: 1.47.0.0 (MINOR continues on top of main's 1.46.0.0; this
  branch is also a MINOR per scale-aware rules — new skill capability).
- CHANGELOG: rewrite this branch's release header v1.45.0.0 → v1.47.0.0.
  Keep both main entries above main's older history.

Adapts to main's eval-first floor (v1.46.0.0 test/skill-coverage-matrix.ts
+ test/skill-coverage-floor.test.ts):
- Register /spec in SKILL_COVERAGE with 3 gate entries + 2 periodic.
- Skill catalog grows 51 → 52. Floor 6/6 structural checks pass.
- Catalog tokens: 4045 → 4116 (+71 for /spec, within v1.46's ≤7000 budget).
- Trim spec frontmatter description to single-paragraph block form to
  respect v1.46's catalog-trim intent (was 14 lines / ~900 chars,
  now 5 lines / ~350 chars; routing prose stays in body sections).
- 363/363 gate-tier tests pass across skill-coverage-floor (309) +
  skill-coverage-matrix (10) + skill-size-budget (3) + parity-suite (4) +
  spec-template-invariants (35) + spec-template-sync (2).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-05-26 18:54:21 -07:00
132 changed files with 10945 additions and 4270 deletions
+17 -89
View File
@@ -2,17 +2,7 @@
name: ios-qa
preamble-tier: 3
version: 1.0.0
description: |
Live-device iOS QA for SwiftUI apps. Connects to a real iPhone via USB
CoreDevice IPv6 tunnel, reads Swift source to understand every screen, then
runs a vision-driven agent loop: screenshot → analyze → decide → act →
verify → repeat. All interaction happens via HTTP to an embedded
StateServer in the app under test. Optionally exposes the device over
Tailscale so remote agents (OpenClaw, Codex, any HTTP-capable agent) can
run iOS QA from anywhere without touching the hardware.
Use when asked to "ios qa", "test my iPhone app", "find bugs on the device",
or "qa the iOS app". (gstack)
Voice triggers (speech-to-text aliases): "iOS quality check", "test the iPhone app", "run iOS QA".
description: Live-device iOS QA for SwiftUI apps. (gstack)
allowed-tools:
- Bash
- Read
@@ -31,6 +21,21 @@ triggers:
<!-- AUTO-GENERATED from SKILL.md.tmpl — do not edit directly -->
<!-- Regenerate: bun run gen:skill-docs -->
## When to invoke this skill
Connects to a real iPhone via USB
CoreDevice IPv6 tunnel, reads Swift source to understand every screen, then
runs a vision-driven agent loop: screenshot → analyze → decide → act →
verify → repeat. All interaction happens via HTTP to an embedded
StateServer in the app under test. Optionally exposes the device over
Tailscale so remote agents (OpenClaw, Codex, any HTTP-capable agent) can
run iOS QA from anywhere without touching the hardware.
Use when asked to "ios qa", "test my iPhone app", "find bugs on the device",
or "qa the iOS app".
Voice triggers (speech-to-text aliases): "iOS quality check", "test the iPhone app", "run iOS QA".
## Preamble (run first)
```bash
@@ -571,84 +576,7 @@ Applies to AskUserQuestion, user replies, and findings. AskUserQuestion Format i
- User-turn override wins: if the current message asks for terse / no explanations / just the answer, skip this section.
- Terse mode (EXPLAIN_LEVEL: terse): no glosses, no outcome-framing layer, shorter responses.
Jargon list, gloss on first use if the term appears:
- idempotent
- idempotency
- race condition
- deadlock
- cyclomatic complexity
- N+1
- N+1 query
- backpressure
- memoization
- eventual consistency
- CAP theorem
- CORS
- CSRF
- XSS
- SQL injection
- prompt injection
- DDoS
- rate limit
- throttle
- circuit breaker
- load balancer
- reverse proxy
- SSR
- CSR
- hydration
- tree-shaking
- bundle splitting
- code splitting
- hot reload
- tombstone
- soft delete
- cascade delete
- foreign key
- composite index
- covering index
- OLTP
- OLAP
- sharding
- replication lag
- quorum
- two-phase commit
- saga
- outbox pattern
- inbox pattern
- optimistic locking
- pessimistic locking
- thundering herd
- cache stampede
- bloom filter
- consistent hashing
- virtual DOM
- reconciliation
- closure
- hoisting
- tail call
- GIL
- zero-copy
- mmap
- cold start
- warm start
- green-blue deploy
- canary deploy
- feature flag
- kill switch
- dead letter queue
- fan-out
- fan-in
- debounce
- throttle (UI)
- hydration mismatch
- memory leak
- GC pause
- heap fragmentation
- stack overflow
- null pointer
- dangling pointer
- buffer overflow
Curated jargon list lives at `~/.claude/skills/gstack/scripts/jargon-list.json` (80+ terms). On the first jargon term you encounter this session, Read that file once; treat the `terms` array as the canonical list. The list is repo-owned and may grow between releases.
## Completeness Principle — Boil the Lake
+145
View File
@@ -27,7 +27,32 @@ export interface ResolveImpl {
const defaultSpawn: SpawnImpl = (cmd, args) => spawnSync(cmd, args, { stdio: 'pipe', timeout: 60_000 });
/**
* Default resolver. Uses `dns.lookup` (getaddrinfo, goes through mDNSResponder
* on macOS) instead of `dns.resolve6` (libresolv, does NOT consult mDNS on
* recent macOS — returns ESERVFAIL for `*.coredevice.local`).
*
* Prefer the IPv6 record but fall back to whatever getaddrinfo returns.
*/
const defaultResolve: ResolveImpl = async (hostname) => {
const dns = await import('dns');
return new Promise((resolve, reject) => {
dns.lookup(hostname, { family: 6, all: true }, (err, addrs) => {
if (err) { reject(err); return; }
const ipv6 = (addrs ?? []).filter((a) => a.family === 6).map((a) => a.address);
if (ipv6.length === 0) { reject(new Error(`no IPv6 records for ${hostname}`)); return; }
resolve(ipv6);
});
});
};
/**
* Last-resort resolver using `dns.resolve6`. Kept for backwards compatibility
* and for environments where mDNSResponder is not in the resolver chain. On
* macOS 26.x (Darwin 25.x) this typically fails with ESERVFAIL — see comment
* on `defaultResolve` above.
*/
const legacyResolve6: ResolveImpl = async (hostname) => {
const dns = await import('dns');
return new Promise((resolve, reject) => {
dns.resolve6(hostname, (err, addrs) => {
@@ -69,6 +94,89 @@ export function listDevices(spawn: SpawnImpl = defaultSpawn): DeviceEntry[] {
}
}
/**
* Resolve the CoreDevice tunnel's IPv6 address from `devicectl device info
* details --json-output`. This is the most reliable path on macOS 26.x: the
* tunnel IPv6 lives in `result.connectionProperties.tunnelIPAddress` and is
* authoritative (it's what CoreDevice itself uses to route).
*
* A side effect of running `devicectl device info details` is that it forces
* CoreDevice to bring up / refresh the tunnel session, which is why we prefer
* this over mDNS even on machines where mDNS works.
*
* Returns null when the device isn't found, isn't tunneled, or devicectl
* fails — callers should fall through to mDNS resolution.
*/
export function getDeviceTunnelIPv6FromDevicectl(
udid: string,
spawn: SpawnImpl = defaultSpawn,
): string | null {
const tmp = join(tmpdir(), `devicectl-details-${process.pid}-${Date.now()}.json`);
try {
const r = spawn('xcrun', ['devicectl', 'device', 'info', 'details', '--device', udid, '--json-output', tmp]);
if (r.status !== 0) return null;
const raw = readFileSync(tmp, 'utf-8');
const obj = JSON.parse(raw);
// `result.connectionProperties.tunnelIPAddress` is the canonical location.
// Some Xcode/CoreDevice versions also surface it under `result.tunnel.ipAddress`
// — accept either.
const conn = obj?.result?.connectionProperties as Record<string, unknown> | undefined;
const tunnel = obj?.result?.tunnel as Record<string, unknown> | undefined;
const addr = (conn?.tunnelIPAddress ?? tunnel?.ipAddress) as string | undefined;
if (typeof addr === 'string' && addr.includes(':')) return addr;
return null;
} catch {
return null;
} finally {
try { rmSync(tmp, { force: true }); } catch { /* ignore */ }
}
}
/**
* Start a periodic devicectl `info details` poll that keeps the CoreDevice
* tunnel session alive. Xcode 26's CoreDevice only holds the tunnel up while
* a devicectl command is in-flight or Xcode itself is debugging. Without
* something poking it, the tunnel IPv6 becomes unroutable within seconds —
* `curl` to the address times out even though the address looks valid.
*
* Implementation note: we chose `device info details` (cheap, ~10ms of CPU
* per tick, no persistent child process) over `device console` (which would
* keep the tunnel up continuously but spams stdout, can wedge on backpressure,
* and is harder to kill cleanly). The 5-second interval is comfortably under
* the empirically-observed tunnel teardown timeout (~10-15s of idle).
*
* Returns a `stop()` function that cancels the timer. Safe to call multiple
* times.
*/
export function startTunnelKeepalive(
udid: string,
opts: { intervalMs?: number; spawn?: SpawnImpl } = {},
): { stop: () => void } {
const intervalMs = opts.intervalMs ?? 5_000;
const spawn = opts.spawn ?? defaultSpawn;
let stopped = false;
const tick = () => {
if (stopped) return;
// Fire-and-forget: ignore result, the side-effect of the spawn is what
// keeps the tunnel up. We deliberately do not use the JSON output here.
try {
const tmp = join(tmpdir(), `devicectl-keepalive-${process.pid}-${Date.now()}.json`);
spawn('xcrun', ['devicectl', 'device', 'info', 'details', '--device', udid, '--json-output', tmp]);
try { rmSync(tmp, { force: true }); } catch { /* ignore */ }
} catch { /* ignore — next tick will retry */ }
};
const handle = setInterval(tick, intervalMs);
// Don't keep the event loop alive just for this — daemon owns the lifecycle.
if (typeof handle.unref === 'function') handle.unref();
return {
stop: () => {
if (stopped) return;
stopped = true;
clearInterval(handle);
},
};
}
/**
* Resolve the CoreDevice tunnel's IPv6 address for a device. The hostname is
* derived from the device name as printed by `devicectl list devices`. The
@@ -95,6 +203,43 @@ export async function getDeviceTunnelIPv6(
}
}
/**
* Resolve a device's tunnel IPv6 using every strategy we know, in order of
* decreasing reliability:
*
* 1. `devicectl device info details --json-output` (most reliable on
* macOS 26.x; also has the useful side-effect of bumping the tunnel).
* 2. mDNS via `dns.lookup` (getaddrinfo path — does consult mDNSResponder
* on macOS, unlike `dns.resolve6`).
* 3. mDNS via `dns.resolve6` (legacy path — kept for backwards
* compatibility; will ESERVFAIL on recent macOS).
*
* Returns the first address that any strategy yields, or null.
*/
export async function resolveTunnelIPv6(opts: {
udid: string;
deviceName: string;
spawn?: SpawnImpl;
resolve?: ResolveImpl;
legacyResolve?: ResolveImpl;
}): Promise<string | null> {
const spawn = opts.spawn ?? defaultSpawn;
const resolveLookup = opts.resolve ?? defaultResolve;
const resolveLegacy = opts.legacyResolve ?? legacyResolve6;
// 1. devicectl-based
const fromDevicectl = getDeviceTunnelIPv6FromDevicectl(opts.udid, spawn);
if (fromDevicectl) return fromDevicectl;
// 2. mDNS via dns.lookup
const fromLookup = await getDeviceTunnelIPv6(opts.deviceName, resolveLookup);
if (fromLookup) return fromLookup;
// 3. last-resort: legacy dns.resolve6
const fromLegacy = await getDeviceTunnelIPv6(opts.deviceName, resolveLegacy);
return fromLegacy;
}
/**
* Check whether a specific bundle ID has a running process on the device.
*/
+16
View File
@@ -21,6 +21,7 @@ import { mintForCaller } from './auth-mint';
import { classifyRoute, proxyToDevice, type DeviceTunnel } from './proxy';
import { writeAudit, writeAttempt, sanitizeReplacer } from './audit';
import { bootstrapTunnel } from './tunnel-bootstrap';
import { startTunnelKeepalive } from './devicectl';
import type { Capability } from './types';
interface DaemonOptions {
@@ -402,6 +403,12 @@ if (import.meta.main) {
// Default tunnelProvider: when GSTACK_IOS_TARGET_UDID (or a default with
// any connected paired device) is set, bootstrap a real CoreDevice tunnel.
// Otherwise return null (proxy will return 503 device_not_connected).
//
// After a successful bootstrap we spawn a periodic devicectl `info details`
// call to keep the CoreDevice tunnel session alive — Xcode 26's CoreDevice
// only holds the tunnel up while a devicectl command is in-flight, so
// without a poke every few seconds the IPv6 becomes unroutable.
let keepalive: { stop: () => void } | null = null;
const realTunnelProvider = async () => {
const result = await bootstrapTunnel({
udid: targetUDID,
@@ -411,9 +418,18 @@ if (import.meta.main) {
process.stderr.write(`bootstrap error: ${result.error}${result.detail ? ' — ' + result.detail : ''}\n`);
return null;
}
if (keepalive) keepalive.stop();
keepalive = startTunnelKeepalive(result.tunnel.udid);
return result.tunnel;
};
const shutdown = () => {
if (keepalive) { keepalive.stop(); keepalive = null; }
};
process.on('SIGINT', shutdown);
process.on('SIGTERM', shutdown);
process.on('exit', shutdown);
startDaemon({
loopbackPort: port,
tailnetEnabled: tailnet,
+16 -3
View File
@@ -17,7 +17,7 @@ import { randomBytes } from 'crypto';
import type { DeviceTunnel } from './proxy';
import {
listDevices,
getDeviceTunnelIPv6,
resolveTunnelIPv6,
isAppRunning,
launchApp,
copyFileFromAppContainer,
@@ -97,8 +97,21 @@ export async function bootstrapTunnel(opts: BootstrapOptions): Promise<Bootstrap
}
}
// Step 3: resolve tunnel IPv6
const ipv6 = await getDeviceTunnelIPv6(target.name, resolve);
// Step 3: resolve tunnel IPv6. Try devicectl `info details` first (most
// reliable on macOS 26.x), fall through to mDNS via dns.lookup, then
// dns.resolve6 as a last-ditch fallback. See devicectl.ts:resolveTunnelIPv6
// for the rationale.
// When tests inject `resolve`, use it for both the mDNS-lookup path AND the
// legacy resolve6 path — otherwise the legacy path would make a real DNS
// call. In production, only `resolve` is set (to the dns.lookup-based
// default) and the legacy path uses the real dns.resolve6.
const ipv6 = await resolveTunnelIPv6({
udid: target.identifier,
deviceName: target.name,
spawn,
resolve,
legacyResolve: resolve,
});
if (!ipv6) {
return { ok: false, error: 'resolve_failed', detail: target.name };
}
+156 -1
View File
@@ -4,7 +4,12 @@
import { describe, test, expect } from 'bun:test';
import { bootstrapTunnel } from '../src/tunnel-bootstrap';
import type { SpawnImpl } from '../src/devicectl';
import {
getDeviceTunnelIPv6FromDevicectl,
resolveTunnelIPv6,
startTunnelKeepalive,
type SpawnImpl,
} from '../src/devicectl';
import { writeFileSync } from 'fs';
interface ScriptedCall {
@@ -142,6 +147,12 @@ describe('bootstrapTunnel', () => {
jsonOutput: { result: { runningProcesses: [{ executable: 'file:///private/var/containers/Bundle/Application/.../com.test.app/com.test', processIdentifier: 1234 }] } },
stdout: 'com.test',
},
{
// devicectl device info details (devicectl-based IPv6 resolution).
// Return no tunnelIPAddress so we fall through to the injected resolver.
argsMatch: /devicectl device info details/,
jsonOutput: { result: { connectionProperties: {} } },
},
]);
const r = await bootstrapTunnel({
bundleId: 'com.test',
@@ -173,6 +184,12 @@ describe('bootstrapTunnel', () => {
jsonOutput: { result: { runningProcesses: [{ executable: 'file:///var/containers/Bundle/Application/X/com.test.app/com.test', processIdentifier: 5678 }] } },
stdout: '/com.test.app/',
},
{
// devicectl-based IPv6 resolution succeeds — returns the tunnel
// address directly, so the injected resolveImpl is never called.
argsMatch: /devicectl device info details/,
jsonOutput: { result: { connectionProperties: { tunnelIPAddress: 'fd99::beef' } } },
},
{
argsMatch: /devicectl device copy from/,
destOutput: 'BOOT-TOKEN-XYZ-123\n',
@@ -233,6 +250,11 @@ describe('bootstrapTunnel', () => {
// jsonOutput body contains the bundle id path, so isAppRunning() returns true.
jsonOutput: { result: { runningProcesses: [{ executable: 'file:///var/containers/Bundle/Application/X/com.test.app/com.test' }] } },
},
{
// devicectl device info details returns no tunnel address.
argsMatch: /devicectl device info details/,
jsonOutput: { result: { connectionProperties: {} } },
},
]);
const r = await bootstrapTunnel({
bundleId: 'com.test',
@@ -258,6 +280,10 @@ describe('bootstrapTunnel', () => {
argsMatch: /devicectl device info processes -d B/,
jsonOutput: { result: { runningProcesses: [{ executable: 'file:///var/containers/Bundle/Application/X/com.test.app/com.test' }] } },
},
{
argsMatch: /devicectl device info details --device B/,
jsonOutput: { result: { connectionProperties: { tunnelIPAddress: 'fd00::b' } } },
},
{
argsMatch: /devicectl device copy from --device B/,
destOutput: 'TOKEN\n',
@@ -274,3 +300,132 @@ describe('bootstrapTunnel', () => {
if (r.ok) expect(r.tunnel.udid).toBe('B');
});
});
describe('getDeviceTunnelIPv6FromDevicectl', () => {
test('extracts tunnelIPAddress from connectionProperties', () => {
const spawn = makeSpawn([
{
argsMatch: /devicectl device info details --device TEST-UDID/,
jsonOutput: { result: { connectionProperties: { tunnelIPAddress: 'fde4:2827:528e::1' } } },
},
]);
expect(getDeviceTunnelIPv6FromDevicectl('TEST-UDID', spawn)).toBe('fde4:2827:528e::1');
});
test('falls back to result.tunnel.ipAddress when connectionProperties absent', () => {
const spawn = makeSpawn([
{
argsMatch: /devicectl device info details/,
jsonOutput: { result: { tunnel: { ipAddress: 'fd00::dead:beef' } } },
},
]);
expect(getDeviceTunnelIPv6FromDevicectl('UDID', spawn)).toBe('fd00::dead:beef');
});
test('returns null when devicectl exits non-zero', () => {
const spawn = makeSpawn([
{ argsMatch: /devicectl device info details/, exitCode: 1, stderr: 'no such device' },
]);
expect(getDeviceTunnelIPv6FromDevicectl('UDID', spawn)).toBeNull();
});
test('returns null when tunnelIPAddress missing or non-string', () => {
const spawn = makeSpawn([
{ argsMatch: /devicectl device info details/, jsonOutput: { result: { connectionProperties: {} } } },
]);
expect(getDeviceTunnelIPv6FromDevicectl('UDID', spawn)).toBeNull();
});
});
describe('resolveTunnelIPv6 fallback chain', () => {
test('prefers devicectl-based resolution', async () => {
const spawn = makeSpawn([
{
argsMatch: /devicectl device info details/,
jsonOutput: { result: { connectionProperties: { tunnelIPAddress: 'fd11::1' } } },
},
]);
let resolveCalled = false;
const addr = await resolveTunnelIPv6({
udid: 'U',
deviceName: 'Test',
spawn,
resolve: async () => { resolveCalled = true; return ['fd99::99']; },
legacyResolve: async () => { resolveCalled = true; return ['fdAA::AA']; },
});
expect(addr).toBe('fd11::1');
expect(resolveCalled).toBe(false);
});
test('falls through to dns.lookup when devicectl yields no address', async () => {
const spawn = makeSpawn([
{ argsMatch: /devicectl device info details/, jsonOutput: { result: { connectionProperties: {} } } },
]);
let legacyCalled = false;
const addr = await resolveTunnelIPv6({
udid: 'U',
deviceName: 'Test',
spawn,
resolve: async () => ['fd22::2'],
legacyResolve: async () => { legacyCalled = true; return ['fdAA::AA']; },
});
expect(addr).toBe('fd22::2');
expect(legacyCalled).toBe(false);
});
test('falls through to legacy resolve6 when both devicectl and dns.lookup fail', async () => {
const spawn = makeSpawn([
{ argsMatch: /devicectl device info details/, exitCode: 1 },
]);
const addr = await resolveTunnelIPv6({
udid: 'U',
deviceName: 'Test',
spawn,
resolve: async () => { throw new Error('ESERVFAIL'); },
legacyResolve: async () => ['fd33::3'],
});
expect(addr).toBe('fd33::3');
});
test('returns null when all three strategies fail', async () => {
const spawn = makeSpawn([
{ argsMatch: /devicectl device info details/, exitCode: 1 },
]);
const addr = await resolveTunnelIPv6({
udid: 'U',
deviceName: 'Test',
spawn,
resolve: async () => { throw new Error('ESERVFAIL'); },
legacyResolve: async () => { throw new Error('ESERVFAIL'); },
});
expect(addr).toBeNull();
});
});
describe('startTunnelKeepalive', () => {
test('invokes devicectl on each interval tick', async () => {
const calls: string[] = [];
const spawn: SpawnImpl = ((cmd: string, args: string[]) => {
calls.push(`${cmd} ${args.slice(0, 4).join(' ')}`);
return makeReturn(0, '{}', '');
}) as SpawnImpl;
const ka = startTunnelKeepalive('UDID-X', { intervalMs: 20, spawn });
await new Promise((res) => setTimeout(res, 75));
ka.stop();
const before = calls.length;
// After stop, no more calls.
await new Promise((res) => setTimeout(res, 50));
expect(calls.length).toBe(before);
expect(before).toBeGreaterThanOrEqual(2);
expect(calls[0]).toContain('devicectl');
expect(calls[0]).toContain('device info details');
});
test('stop() is idempotent', () => {
const spawn: SpawnImpl = (() => makeReturn(0, '', '')) as SpawnImpl;
const ka = startTunnelKeepalive('U', { intervalMs: 1_000, spawn });
ka.stop();
ka.stop();
// no throw
});
});