Skip to content

Commit b601997

Browse files
autopilot: address PR #19 review feedback
Why: - Resolve actionable GitHub review feedback for PR #19. Changed: - Closed four review findings with small fail-closed setup fixes: complete lease identity validation, selected-path service-identity retries, blocking fresh setup over existing state-root artifacts, and refreshing the macOS dashboard on an already-ready preview. Verification: - npm run agent:complete -- --session-dir [controller artifact: jobs/pr-19/20260825-164548-38b5262d-5f36-4760-a75c-efad32bfa3dc/task-session] - npm run agent:context -- --paths-file [controller artifact: jobs/pr-19/20260825-164548-38b5262d-5f36-4760-a75c-efad32bfa3dc/actual-paths.txt] --session-dir [controller artifact: jobs/pr-19/20260825-164548-38b5262d-5f36-4760-a75c-efad32bfa3dc/task-session] - npm run agent:verify -- --profile implementation --paths-file [controller artifact: jobs/pr-19/20260825-164548-38b5262d-5f36-4760-a75c-efad32bfa3dc/task-session/verify/implementation/paths.txt] --session-dir [controller artifact: jobs/pr-19/20260825-164548-38b5262d-5f36-4760-a75c-efad32bfa3dc/task-session] - npm run agent:verify -- --profile spec-only --paths-file [controller artifact: jobs/pr-19/20260825-164548-38b5262d-5f36-4760-a75c-efad32bfa3dc/task-session/verify/spec-only/paths.txt] --session-dir [controller artifact: jobs/pr-19/20260825-164548-38b5262d-5f36-4760-a75c-efad32bfa3dc/task-session] Affected: - .github/workflows/ci.yml - CHANGELOG.md - CONTRIBUTING.md - Casks/simulator-broker.rb - Formula/simbroker.rb - README.md - agent-harness/package-lock.json - agent-harness/package.json - app/README.md - app/Sources/BrokerDashboardStore.swift - app/Sources/BrokerLocalCommandClient.swift - app/Sources/BrokerOnboardingCommands.swift - app/Sources/BrokerServiceClient.swift - app/Sources/BrokerSetupModels.swift - app/Sources/BrokerSnapshotLoader.swift - app/Sources/RootView.swift - app/Sources/SetupPlanDevicesView.swift - app/Sources/SetupPlanPrerequisitesView.swift - app/Sources/SetupPlanSheet.swift - app/Sources/SharedViews.swift - app/Tests/BrokerDashboardStoreTests.swift - app/Tests/BrokerLocalCommandClientTests.swift - app/Tests/BrokerOnboardingCommandsTests.swift - app/Tests/BrokerRuntimePathsTests.swift - app/Tests/BrokerServiceClientTests.swift - app/Tests/BrokerSnapshotLoaderTests.swift - broker-core/containment.mjs - broker-core/error-contract.mjs - broker-core/index.mjs - broker-core/simctl.mjs - broker-core/test/broker-core.test.mjs - client/bin/simbroker.mjs - client/command-dispatch.mjs - client/public-surface.mjs - client/service/service-client.mjs - client/setup-preflight.mjs - client/setup-provisioning-worker.mjs - client/setup-provisioning.mjs - client/test/brokerd.test.mjs - client/test/install-distribution.test.mjs - client/test/public-surface.test.mjs - client/test/setup-preflight.test.mjs - client/test/simbroker.test.mjs - docs/concepts.md - docs/getting-started.md - docs/status.md - docs/test/front-door.test.mjs - examples/harness-adoption/README.md - examples/harness-adoption/sample-consumer-repo/.simulator-broker/project.json - examples/harness-adoption/sample-consumer-repo/README.md - packages/simbroker/README.md - scripts/install_smoke.sh - scripts/package_distribution.sh - spec/README.md - spec/architecture.md - spec/build-and-test.md - spec/global-simulator-broker.md - spec/project-structure.md - spec/tasks/README.md - spec/tasks/guided-simbroker-setup.md Refs: - #19 - #19 (comment) - #19 (comment) - #19 (comment) - #19 (comment) Session: - task-session: [controller artifact: jobs/pr-19/20260825-164548-38b5262d-5f36-4760-a75c-efad32bfa3dc/task-session] - report: [controller artifact: jobs/pr-19/20260825-164548-38b5262d-5f36-4760-a75c-efad32bfa3dc/report.md]
1 parent 3739272 commit b601997

8 files changed

Lines changed: 241 additions & 20 deletions

File tree

CHANGELOG.md

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -26,18 +26,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2626
- Guided setup binds existing-host confirmation to the requested `--host-id`,
2727
uses the separate device-type inventory when a runtime omits
2828
`supportedDeviceTypes`, and fails closed when known-projects or lease/pin
29-
JSON cannot be loaded or is not a valid identity record instead of reporting
30-
`ready`. Preview probes service identity even when no host is configured yet,
31-
so a socket occupied by a different broker is blocked before device creation.
29+
JSON cannot be loaded or is not a complete identity record instead of
30+
reporting `ready`. A missing host-config with an existing registry, lease,
31+
or pin record in the selected state root is blocked rather than treated as
32+
a confirmable fresh plan. Preview probes service identity even when no host
33+
is configured yet, so a socket occupied by a different broker is blocked
34+
before device creation.
3235
`ready` also requires the app snapshot to be at least as new as
3336
known-projects, lease/pin records, and the lease/pin directories, and to
3437
match the current doctor-record set, so deletions are finishing work rather
3538
than a stale `ready`. Fallback device-type selection is identifier-stable
36-
when preferred names are absent. Setup failure recovery commands keep the
37-
selected `--host-config`, `--state-root`, and `--service-socket` paths.
38-
The macOS app keeps automatic service/snapshot finishing off the first-time
39-
review sheet. Setup apply Stop/timeout waits for rollback inventory calls
40-
before escalating to SIGKILL.
39+
when preferred names are absent. Setup failure recovery commands, including
40+
service-identity retries, keep the selected `--host-config`, `--state-root`,
41+
and `--service-socket` paths. The macOS app keeps automatic service/snapshot
42+
finishing off the first-time review sheet and refreshes the dashboard when
43+
setup preview is already `ready`. Setup apply Stop/timeout waits for rollback
44+
inventory calls before escalating to SIGKILL.
4145
- Public CI no longer runs the full-repo home-path leak scan on
4246
GitHub-hosted runners. Ubuntu (10 minutes) runs skill ownership,
4347
`test:broker-core`, and `test:harness-adoption`. macOS (15 minutes)

app/Sources/BrokerDashboardStore.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -566,7 +566,9 @@ final class BrokerDashboardStore {
566566
setupPlan = nil
567567
pendingSetupConfirmation = nil
568568
if plan.status == .ready {
569+
try await requireRefreshAfterMutation()
569570
setupPhase = .idle
571+
lastErrorMessage = nil
570572
setActionMessage("Setup complete — brokerd is running and all managed simulators are healthy.")
571573
return
572574
}

app/Tests/BrokerDashboardStoreTests.swift

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -780,6 +780,78 @@ final class BrokerDashboardStoreTests: XCTestCase {
780780
XCTAssertEqual(store.setupPhase, .idle)
781781
}
782782

783+
func testGuidedSetupReadyPreviewRefreshesDashboardBeforeSuccess() async throws {
784+
let runtimePaths = BrokerRuntimePaths(
785+
stateRoot: URL(fileURLWithPath: "/tmp/simbroker-ready-refresh-state"),
786+
hostConfigURL: URL(fileURLWithPath: "/tmp/simbroker-ready-refresh-host.json"),
787+
configuredCLIURL: URL(fileURLWithPath: "/tmp/fake-simbroker")
788+
)
789+
let staleState = BrokerLoadedState(
790+
paths: runtimePaths,
791+
tooling: BrokerToolingState(cliPath: runtimePaths.configuredCLIURL, hostConfigExists: false, installMetadata: nil),
792+
service: nil,
793+
snapshot: nil
794+
)
795+
let readySnapshot = try loadFixture(named: "busy-snapshot")
796+
let readyState = BrokerLoadedState(
797+
paths: runtimePaths,
798+
tooling: BrokerToolingState(cliPath: runtimePaths.configuredCLIURL, hostConfigExists: true, installMetadata: nil),
799+
service: BrokerServiceMetadata(
800+
hostConfigPath: runtimePaths.hostConfigURL.path,
801+
pid: 321,
802+
socketPath: "/tmp/simbroker-ready-refresh.sock",
803+
startedAt: "2026-04-09T10:00:00Z",
804+
stateRoot: runtimePaths.stateRoot.path,
805+
transport: "unix-http"
806+
),
807+
snapshot: readySnapshot
808+
)
809+
let loader = SequencedSnapshotLoader(states: [readyState])
810+
let localRunner = RecordingLocalCommandRunner()
811+
let setupPlan = try makeSetupPlan(
812+
status: "ready",
813+
confirmationRequired: false,
814+
hostConfigured: true,
815+
includeRuntime: false,
816+
createCount: 0
817+
)
818+
await localRunner.enqueue(
819+
BrokerCLICommandEnvelope(
820+
error: nil,
821+
exitCode: 0,
822+
ok: true,
823+
reasonCode: nil,
824+
setupPlan: setupPlan,
825+
started: nil,
826+
status: "ready",
827+
unchanged: nil
828+
)
829+
)
830+
let store = BrokerDashboardStore(
831+
loader: loader,
832+
commandClient: RecordingCommandClient(),
833+
localCommandRunner: localRunner,
834+
runtimePaths: runtimePaths
835+
)
836+
store.loadedState = staleState
837+
838+
XCTAssertEqual(store.startupState, .needsHostBootstrap)
839+
840+
store.requestGuidedSetup()
841+
try await waitUntil {
842+
await MainActor.run { store.setupPhase == .idle && store.lastActionMessage != nil }
843+
}
844+
845+
let invocations = await localRunner.invocations()
846+
XCTAssertEqual(invocations.count, 1)
847+
XCTAssertEqual(store.lastActionMessage, "Setup complete — brokerd is running and all managed simulators are healthy.")
848+
XCTAssertEqual(store.startupState, .ready)
849+
XCTAssertEqual(store.loadedState?.tooling.hostConfigExists, true)
850+
XCTAssertNotNil(store.loadedState?.snapshot)
851+
let loadCount = await loader.loadCount()
852+
XCTAssertEqual(loadCount, 1)
853+
}
854+
783855
func testGuidedSetupAutomaticFinishingDoesNotPresentSetupSheet() async throws {
784856
let runtimePaths = BrokerRuntimePaths(
785857
stateRoot: URL(fileURLWithPath: "/tmp/simbroker-auto-finish-state"),

broker-core/index.mjs

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1582,6 +1582,17 @@ function setupStateContainsLeaseOrPinRecords(paths) {
15821582
}
15831583
}
15841584

1585+
function setupStateContainsExistingBrokerArtifacts(paths) {
1586+
try {
1587+
if (fs.existsSync(paths.registryPath) && fs.statSync(paths.registryPath).isFile()) {
1588+
return true;
1589+
}
1590+
return setupStateContainsLeaseOrPinRecords(paths);
1591+
} catch {
1592+
return true;
1593+
}
1594+
}
1595+
15851596
function setupExistingHostState(paths, inventory, options = {}) {
15861597
let hostConfig;
15871598
try {
@@ -1772,11 +1783,49 @@ function setupExistingHostState(paths, inventory, options = {}) {
17721783
};
17731784
}
17741785

1786+
function blockedSetupExistingStateWithoutHost(paths, options = {}) {
1787+
const hostId = requestedSetupHostId(options)
1788+
?? slugifyIdentifier(options.hostId ?? defaultHostId(), defaultHostId());
1789+
const planId = setupPlanFingerprint({
1790+
configured: false,
1791+
devices: [],
1792+
hostConfig: null,
1793+
hostId,
1794+
runtime: null,
1795+
});
1796+
return {
1797+
hostConfig: null,
1798+
preview: {
1799+
command: "setup",
1800+
confirmation: { createCount: 0, required: false, reuseCount: 0 },
1801+
devices: [],
1802+
host: { action: "keep", configured: false, hostId },
1803+
mode: "preview",
1804+
nextSteps: ["simbroker doctor"],
1805+
ok: true,
1806+
planId,
1807+
prerequisites: [{
1808+
id: "state-root",
1809+
remediationCommands: ["simbroker doctor"],
1810+
status: "blocked",
1811+
summary: "The selected state root already contains broker state and setup will not create a new host over it.",
1812+
}],
1813+
runtime: null,
1814+
schemaVersion: SETUP_SCHEMA_VERSION,
1815+
service: { action: "blocked", running: false },
1816+
status: "blocked",
1817+
},
1818+
};
1819+
}
1820+
17751821
function buildSetupPlan(paths, options = {}) {
17761822
const inventory = readSimctlInventory(options);
17771823
if (fs.existsSync(paths.hostConfigPath)) {
17781824
return setupExistingHostState(paths, inventory, options);
17791825
}
1826+
if (setupStateContainsExistingBrokerArtifacts(paths)) {
1827+
return blockedSetupExistingStateWithoutHost(paths, options);
1828+
}
17801829

17811830
const runtime = selectRuntimeForSetup(inventory, options.iosVersion);
17821831
const hostId = slugifyIdentifier(options.hostId ?? defaultHostId(), defaultHostId());
@@ -2343,10 +2392,28 @@ function listJsonFiles(dirPath) {
23432392
.map(readJson);
23442393
}
23452394

2395+
function requirePositiveIntegerField(value, name) {
2396+
const normalized = normalizePositiveInteger(value);
2397+
if (normalized === null) {
2398+
throw new BrokerError(`${name} must be a positive integer.`, {
2399+
field: name,
2400+
reasonCode: "invalid-config",
2401+
});
2402+
}
2403+
return normalized;
2404+
}
2405+
23462406
function assertValidLeaseRecord(record) {
23472407
requireObject(record, "lease");
23482408
requireString(record.leaseId, "lease.leaseId");
23492409
requireString(record.alias, "lease.alias");
2410+
requireString(record.simulatorId, "lease.simulatorId");
2411+
requireString(record.actorType, "lease.actorType");
2412+
requireString(record.actorId, "lease.actorId");
2413+
requireString(record.projectId, "lease.projectId");
2414+
requireString(record.purposeId, "lease.purposeId");
2415+
requireString(record.startedAt, "lease.startedAt");
2416+
requirePositiveIntegerField(record.ownerPid, "lease.ownerPid");
23502417
return record;
23512418
}
23522419

broker-core/test/broker-core.test.mjs

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1215,6 +1215,71 @@ test("setup blocks an existing host when doctor-relevant state files are malform
12151215
assert.equal(emptyPinBlocked.status, "blocked");
12161216
assert.ok(emptyPinBlocked.prerequisites.some((issue) =>
12171217
issue.id === "leases" && issue.status === "blocked"));
1218+
1219+
fs.rmSync(path.join(resolvedPaths.pinsDir, "empty-pin.json"));
1220+
writeJson(path.join(resolvedPaths.leasesDir, "partial-lease.json"), {
1221+
alias: "ui-1",
1222+
leaseId: "partial-lease",
1223+
});
1224+
const partialLeaseBlocked = previewSetupBroker(resolvedPaths, {
1225+
simctlAdapter: paths.simctl.adapter,
1226+
});
1227+
assert.equal(partialLeaseBlocked.status, "blocked");
1228+
assert.ok(partialLeaseBlocked.prerequisites.some((issue) =>
1229+
issue.id === "leases" && issue.status === "blocked"));
1230+
assert.throws(() => applySetupBroker(resolvedPaths, {
1231+
confirmPlanId: partialLeaseBlocked.planId,
1232+
simctlAdapter: paths.simctl.adapter,
1233+
}), (error) => error.payload?.reasonCode === "setup-prerequisite-failed");
1234+
assert.equal(fs.existsSync(path.join(resolvedPaths.leasesDir, "partial-lease.json")), true);
1235+
});
1236+
1237+
test("setup blocks a missing host config when the state root already has broker records", () => {
1238+
const paths = makePaths();
1239+
const resolvedPaths = brokerPaths(paths);
1240+
fs.mkdirSync(resolvedPaths.stateRoot, { recursive: true });
1241+
writeJson(resolvedPaths.registryPath, {
1242+
aliases: {},
1243+
updatedAt: "2026-01-01T00:00:00.000Z",
1244+
version: 1,
1245+
});
1246+
1247+
const registryBlocked = previewSetupBroker(resolvedPaths, {
1248+
hostId: "fresh-over-state",
1249+
simctlAdapter: paths.simctl.adapter,
1250+
});
1251+
assert.equal(registryBlocked.status, "blocked");
1252+
assert.equal(registryBlocked.confirmation.required, false);
1253+
assert.ok(registryBlocked.prerequisites.some((issue) =>
1254+
issue.id === "state-root" && issue.status === "blocked"));
1255+
assert.throws(() => applySetupBroker(resolvedPaths, {
1256+
confirmPlanId: registryBlocked.planId,
1257+
hostId: "fresh-over-state",
1258+
simctlAdapter: paths.simctl.adapter,
1259+
}), (error) => error.payload?.reasonCode === "setup-prerequisite-failed");
1260+
assert.equal(fs.existsSync(paths.hostConfigPath), false);
1261+
1262+
fs.rmSync(resolvedPaths.registryPath);
1263+
fs.mkdirSync(resolvedPaths.leasesDir, { recursive: true });
1264+
writeJson(path.join(resolvedPaths.leasesDir, "leftover.json"), {
1265+
alias: "ui-1",
1266+
leaseId: "leftover",
1267+
});
1268+
const leftoverBlocked = previewSetupBroker(resolvedPaths, {
1269+
simctlAdapter: paths.simctl.adapter,
1270+
});
1271+
assert.equal(leftoverBlocked.status, "blocked");
1272+
assert.ok(leftoverBlocked.prerequisites.some((issue) =>
1273+
issue.id === "state-root" && issue.status === "blocked"));
1274+
1275+
fs.rmSync(path.join(resolvedPaths.leasesDir, "leftover.json"));
1276+
const emptyLeaseDir = previewSetupBroker(resolvedPaths, {
1277+
hostId: "fresh-empty-state",
1278+
simctlAdapter: paths.simctl.adapter,
1279+
});
1280+
assert.equal(emptyLeaseDir.status, "changes_required");
1281+
assert.equal(emptyLeaseDir.confirmation.required, true);
1282+
assert.equal(emptyLeaseDir.host.configured, false);
12181283
});
12191284

12201285
test("setup blocks an existing host whose registry marks an alias for repair", () => {

client/bin/simbroker.mjs

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -697,8 +697,9 @@ function setupPrerequisiteFromError(error) {
697697
};
698698
}
699699

700-
function serviceIdentityRecoveryCommands(error) {
700+
function serviceIdentityRecoveryCommands(error, paths) {
701701
const actual = error?.payload?.actual;
702+
const retryCommand = setupRecoveryCommand(paths);
702703
if ([actual?.hostConfigPath, actual?.stateRoot, actual?.socketPath]
703704
.every((value) => typeof value === "string" && value.length > 0)) {
704705
return [
@@ -708,10 +709,10 @@ function serviceIdentityRecoveryCommands(error) {
708709
"--state-root", shellQuoteArgument(actual.stateRoot),
709710
"--service-socket", shellQuoteArgument(actual.socketPath),
710711
].join(" "),
711-
"simbroker setup",
712+
retryCommand,
712713
];
713714
}
714-
return ["simbroker service status --json", "simbroker setup"];
715+
return ["simbroker service status --json", retryCommand];
715716
}
716717

717718
async function buildSetupPreview(paths, options) {
@@ -746,7 +747,7 @@ async function buildSetupPreview(paths, options) {
746747
prerequisites: [...corePreview.prerequisites, {
747748
details: error?.payload ?? { error: error?.message ?? String(error) },
748749
id: "service-identity",
749-
remediationCommands: serviceIdentityRecoveryCommands(error),
750+
remediationCommands: serviceIdentityRecoveryCommands(error, paths),
750751
status: "blocked",
751752
summary: error?.message ?? "The running broker service uses different paths.",
752753
}],

client/test/simbroker.test.mjs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -622,9 +622,13 @@ test("setup identity blockers provide a stop command for the running service pat
622622
hostConfigPath: fixture.hostConfigPath,
623623
stateRoot: fixture.stateRoot,
624624
}).serviceSocketPath;
625+
const selectedPaths = resolveBrokerPaths({
626+
hostConfigPath: otherHostConfigPath,
627+
stateRoot: fixture.stateRoot,
628+
});
625629
assert.deepEqual(serviceIdentity.remediationCommands, [
626630
`simbroker service stop --host-config '${fixture.hostConfigPath}' --state-root '${fixture.stateRoot}' --service-socket '${serviceSocketPath}'`,
627-
"simbroker setup",
631+
`simbroker setup --host-config '${otherHostConfigPath}' --state-root '${fixture.stateRoot}' --service-socket '${selectedPaths.serviceSocketPath}'`,
628632
]);
629633

630634
const stopped = spawnSync(process.execPath, [

spec/tasks/guided-simbroker-setup.md

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
# Guided `simbroker setup`
22

33
> **Document ID:** `GSB-SETUP-001`
4-
> **Version:** `1.0.7`
4+
> **Version:** `1.0.8`
55
> **Last Updated:** `2026-08-25`
66
> **Status:** `Active`
77
> **Owner:** `spec-steward`, `ios-dev`
@@ -186,8 +186,11 @@ Apply performs, in order:
186186
- Existing invalid/unhealthy host is blocked without repair/replacement,
187187
lease/pin mutation, or retirement. Invalid known-projects or lease/pin JSON
188188
is the same class of existing-host failure: diagnose with `simbroker doctor`,
189-
do not report `ready`. Valid JSON that is not a lease or pin identity record,
190-
such as `{}`, is invalid existing-host state.
189+
do not report `ready`. Valid JSON that is not a complete lease or pin
190+
identity record, such as `{}` or a lease with only `leaseId` and `alias`,
191+
is invalid existing-host state. Setup does not treat a missing host-config
192+
as a confirmable fresh plan when the selected state root already contains a
193+
registry file or lease/pin JSON.
191194
- Preview probes the requested service socket even when no host is configured
192195
yet. A running broker with a different host-config, state-root, or socket
193196
identity is a `service-identity` blocker and is not confirmable, so apply
@@ -218,10 +221,12 @@ Service/snapshot-only plans apply immediately without a device sheet.
218221
Automatic finishing progress is tracked on the dashboard without presenting
219222
the first-time review sheet. Blocked plans show commands and no enabled
220223
confirm. Success refreshes to the dashboard and says: `Setup complete —
221-
brokerd is running and all managed simulators are healthy.` Failure of a
222-
confirmable plan refreshes first, preserves the sheet, and shows completed
223-
stages and recovery. Automatic finishing failure stays off the sheet and
224-
surfaces the recovery error on the dashboard.
224+
brokerd is running and all managed simulators are healthy.` A preview that
225+
is already `ready` refreshes the current snapshot before reporting that
226+
success, matching confirmed apply. Failure of a confirmable plan refreshes
227+
first, preserves the sheet, and shows completed stages and recovery.
228+
Automatic finishing failure stays off the sheet and surfaces the recovery
229+
error on the dashboard.
225230

226231
The app accepts only setup schema version 1 and rejects a future or otherwise
227232
unsupported version before presenting or applying its plan. The `@MainActor
@@ -413,6 +418,7 @@ long-running plan/handoff/evaluation, and a passing `agent:complete`.
413418

414419
| Version | Date | Author | Changes |
415420
|---|---|---|---|
421+
| 1.0.8 | 2026-08-25 | `spec-steward`, `ios-dev` | Block partial lease records and missing-host state roots that already contain registry/lease/pin artifacts, keep selected paths on service-identity retries, and refresh the app dashboard on an already-ready setup preview |
416422
| 1.0.7 | 2026-08-25 | `spec-steward`, `ios-dev` | App Stop/timeout SIGTERM window includes rollback attribution inventory and the extra inventory after an indeterminate create failure |
417423
| 1.0.6 | 2026-08-25 | `spec-steward`, `ios-dev` | Fail closed on schema-invalid lease/pin JSON, keep fallback device types order-stable, exclude automatic finishing from the setup sheet, treat doctor-record deletions as snapshot finishing work, and preserve selected path overrides in recovery commands |
418424
| 1.0.5 | 2026-08-25 | `spec-steward`, `ios-dev` | Preview probes service identity on unconfigured hosts, and `ready` requires the snapshot to be at least as new as known-projects and lease/pin JSON |

0 commit comments

Comments
 (0)