@raidiant/notifai 0.1.1 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/commands.js CHANGED
@@ -1,6 +1,6 @@
1
- import { randomBytes } from 'node:crypto';
1
+ import { createHash, randomBytes } from 'node:crypto';
2
2
  import { spawn } from 'node:child_process';
3
- import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
3
+ import { existsSync, mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs';
4
4
  import os from 'node:os';
5
5
  import path from 'node:path';
6
6
  import { parse as parseToml, stringify as stringifyToml } from 'smol-toml';
@@ -476,9 +476,19 @@ export async function statusCommand(deps, requestId, flags) {
476
476
  }
477
477
  deps.io.out(`request ${snapshot.request_id} (${snapshot.event ?? 'no event'}) — ${snapshot.overall}`);
478
478
  for (const d of snapshot.deliveries) {
479
- deps.io.out(` ${d.device_name}: ${d.state} after ${d.attempts} attempt(s)`);
479
+ deps.io.out(` ${d.device_name}:`);
480
+ deps.io.out(` Delivery: ${d.state} after ${d.attempts} attempt(s)`);
481
+ deps.io.out(` Provider Acceptance: ${d.state === 'provider_accepted' ? 'accepted' : 'not recorded'}`);
482
+ if (d.companion_receipt.state === 'observed') {
483
+ const latency = d.companion_receipt.latency_ms;
484
+ deps.io.out(` Companion Receipt: observed at ${d.companion_receipt.observed_at}` +
485
+ (latency === null ? '' : ` (${formatElapsed(latency)} after Provider Acceptance)`));
486
+ }
487
+ else {
488
+ deps.io.out(' Companion Receipt: unknown — not observed; this is not a failure or proof of non-receipt');
489
+ }
480
490
  for (const e of d.events) {
481
- deps.io.out(` ${e.occurred_at} ${e.stage}${e.reason ? ` (${e.reason})` : ''}`);
491
+ deps.io.out(` ${e.occurred_at} ${e.stage}${e.reason ? ` (${e.reason})` : ''}`);
482
492
  }
483
493
  }
484
494
  return EXIT.ok;
@@ -487,6 +497,12 @@ export async function statusCommand(deps, requestId, flags) {
487
497
  return reportError(deps, err);
488
498
  }
489
499
  }
500
+ function formatElapsed(milliseconds) {
501
+ const totalSeconds = Math.floor(milliseconds / 1_000);
502
+ const minutes = Math.floor(totalSeconds / 60);
503
+ const seconds = totalSeconds % 60;
504
+ return minutes === 0 ? `${seconds}s` : `${minutes}m ${seconds}s`;
505
+ }
490
506
  const MEDIA_TYPES = {
491
507
  '.jpg': 'image/jpeg',
492
508
  '.jpeg': 'image/jpeg',
@@ -725,10 +741,16 @@ export function askCommand(deps, question, flags) {
725
741
  deps.io.err(CHOICE_USAGE);
726
742
  return EXIT.usage;
727
743
  }
728
- registerQuestion(sessionId, deps.env, {
729
- question: question.trim(),
730
- ...(choices !== null ? { choices: choices.map((choice) => choice.label) } : {}),
731
- }, (deps.now ?? Date.now)());
744
+ try {
745
+ registerQuestion(sessionId, deps.env, {
746
+ question: question.trim(),
747
+ ...(choices !== null ? { choices: choices.map((choice) => choice.label) } : {}),
748
+ }, (deps.now ?? Date.now)());
749
+ }
750
+ catch (err) {
751
+ deps.io.err(`Could not register the question: ${err instanceof Error ? err.message : String(err)}`);
752
+ return EXIT.failed;
753
+ }
732
754
  if (choices !== null) {
733
755
  deps.io.out(`Answers offered: ${choices.map((choice) => choice.label).join(' / ')}`);
734
756
  }
@@ -1133,14 +1155,11 @@ export async function configSetCommand(deps, key, rawValue, flags) {
1133
1155
  // init
1134
1156
  // ---------------------------------------------------------------------------
1135
1157
  /**
1136
- * Where `npx skills add` fetches the optional agent skill from. The skill's
1137
- * canonical home is the public repository (RafaelVidaurre/notifai), which has
1138
- * nothing pushed to it yet, so no source can resolve today; the guard below
1139
- * keeps `--skills` from installing anything until a tagged release exists to
1140
- * pin this to. Never point this at the private repository — the installer
1141
- * command is printed to users, and only public sources belong in it.
1158
+ * Where `npx skills add` fetches the optional agent skill from. In skills CLI
1159
+ * 1.5.x, `owner/repo@name` selects a skill; a Git ref belongs after `#`.
1160
+ * Keep this immutable and public because the command is printed to users.
1142
1161
  */
1143
- const SKILLS_SOURCE = '';
1162
+ export const SKILLS_SOURCE = 'RafaelVidaurre/notifai#v0.1.2';
1144
1163
  /** Derive a contract-valid project slug from a directory name. */
1145
1164
  export function projectSlugFrom(name) {
1146
1165
  const slug = name
@@ -1150,27 +1169,59 @@ export function projectSlugFrom(name) {
1150
1169
  .slice(0, 64);
1151
1170
  return slug.length > 0 && /^[a-z0-9]/.test(slug) ? slug : 'project';
1152
1171
  }
1153
- /**
1154
- * The setup coordinator that observes each prerequisite and advances the ones
1155
- * this build can perform.
1156
- *
1157
- * Idempotent by construction: every step first observes, then acts only on the
1158
- * gap, so re-running is how you check the setup as much as how you create it.
1159
- * With a human at a terminal it walks them through the missing pieces; run by
1160
- * an agent it never prompts — each optional step is answered by a flag, and
1161
- * whatever only the user can do (signing in, pairing a companion device) is printed as
1162
- * the exact command to hand back to them.
1163
- */
1164
- /**
1165
- * Close a gap the CLI is allowed to close on its own, without asking.
1166
- *
1167
- * Only reached for `by: 'cli'` remedies, which by definition need no human, so
1168
- * this stays silent about what it did — the re-assessment that follows reports
1169
- * the new state, and narrating both is how a setup log becomes unreadable.
1170
- *
1171
- * Returns false when the attempt failed, which is different from declining:
1172
- * a decline leaves an optional gap open and is fine, a failure is not.
1173
- */
1172
+ const DEVICE_BRIDGE_TIMEOUT_MS = 5 * 60 * 1000;
1173
+ const DEVICE_BRIDGE_POLL_MS = 2_000;
1174
+ const PROOF_TIMEOUT_MS = 30_000;
1175
+ const PROOF_POLL_MS = 1_000;
1176
+ function setupProofPath(deps) {
1177
+ let projectDir = path.resolve(deps.cwd);
1178
+ try {
1179
+ projectDir = realpathSync(projectDir);
1180
+ }
1181
+ catch {
1182
+ // A deleted or not-yet-created cwd cannot collide with a real directory:
1183
+ // the resolved absolute path is still a stable local identity for it.
1184
+ }
1185
+ const digest = createHash('sha256').update(projectDir).digest('hex').slice(0, 32);
1186
+ const xdg = deps.env['XDG_STATE_HOME'];
1187
+ const base = xdg && xdg !== '' ? xdg : path.join(os.homedir(), '.local', 'state');
1188
+ return path.join(base, 'notifai', 'setup-proofs', `${digest}.json`);
1189
+ }
1190
+ function readSetupProof(deps) {
1191
+ const file = setupProofPath(deps);
1192
+ if (!existsSync(file))
1193
+ return null;
1194
+ try {
1195
+ const parsed = JSON.parse(readFileSync(file, 'utf8'));
1196
+ return typeof parsed.request_id === 'string' &&
1197
+ typeof parsed.device_id === 'string' &&
1198
+ (typeof parsed.project === 'string' || parsed.project === null) &&
1199
+ typeof parsed.started_at === 'string'
1200
+ ? parsed
1201
+ : null;
1202
+ }
1203
+ catch {
1204
+ // Corrupt local evidence is not readiness. A fresh proof replaces it.
1205
+ return null;
1206
+ }
1207
+ }
1208
+ function writeSetupProof(deps, proof) {
1209
+ const file = setupProofPath(deps);
1210
+ try {
1211
+ mkdirSync(path.dirname(file), { recursive: true });
1212
+ writeFileSync(file, `${JSON.stringify(proof, null, 2)}\n`, { mode: 0o600 });
1213
+ return true;
1214
+ }
1215
+ catch (err) {
1216
+ deps.io.err(`Could not save setup proof ${proof.request_id} at ${file}: ${String(err)}`);
1217
+ return false;
1218
+ }
1219
+ }
1220
+ function observedCompanionReceipt(snapshot, deviceId) {
1221
+ const delivery = snapshot.deliveries.find((candidate) => candidate.device_id === deviceId);
1222
+ const receipt = delivery?.events.find((event) => event.stage === 'companion_received');
1223
+ return delivery && receipt ? { delivery, observedAt: receipt.occurred_at } : null;
1224
+ }
1174
1225
  async function closeGap(deps, state, flags) {
1175
1226
  if (state.id === 'project') {
1176
1227
  const configPath = path.join(deps.cwd, '.notifai', 'config.toml');
@@ -1180,7 +1231,7 @@ async function closeGap(deps, state, flags) {
1180
1231
  existing['project'] = projectSlugFrom(flags.projectId ?? path.basename(deps.cwd));
1181
1232
  mkdirSync(path.dirname(configPath), { recursive: true });
1182
1233
  writeFileSync(configPath, `${stringifyToml(existing)}\n`);
1183
- return true;
1234
+ return 'closed';
1184
1235
  }
1185
1236
  if (state.id === 'hooks') {
1186
1237
  let harness = detectHarness(deps.cwd);
@@ -1191,9 +1242,9 @@ async function closeGap(deps, state, flags) {
1191
1242
  }
1192
1243
  if (harness === null) {
1193
1244
  deps.io.err(`Could not tell which harness to wire. Run: notifai hooks install --harness <${HARNESSES.join('|')}>`);
1194
- return false;
1245
+ return 'failed';
1195
1246
  }
1196
- return hooksInstallCommand(deps, { harness }) === EXIT.ok;
1247
+ return hooksInstallCommand(deps, { harness }) === EXIT.ok ? 'closed' : 'failed';
1197
1248
  }
1198
1249
  if (state.id === 'skill') {
1199
1250
  deps.io.out(`Installing the notifai agent skill (npx skills add ${SKILLS_SOURCE})...`);
@@ -1209,9 +1260,233 @@ async function closeGap(deps, state, flags) {
1209
1260
  deps.io.err('Skill installation failed — run it manually with:');
1210
1261
  deps.io.err(` npx skills add ${SKILLS_SOURCE} --skill notifai`);
1211
1262
  }
1212
- return code === 0;
1263
+ return code === 0 ? 'closed' : 'failed';
1213
1264
  }
1214
- return false;
1265
+ if (state.id === 'proof')
1266
+ return await runSetupProof(deps);
1267
+ return 'failed';
1268
+ }
1269
+ function deviceCanReceive(device) {
1270
+ return (device.registration_healthy &&
1271
+ (device.permission_status === 'authorized' || device.permission_status === 'provisional'));
1272
+ }
1273
+ function readyIosDevices(devices) {
1274
+ return devices.filter((device) => device.platform === 'ios' && deviceCanReceive(device));
1275
+ }
1276
+ function deviceBridgeMessage(devices) {
1277
+ if (devices.length === 0) {
1278
+ return 'Waiting for the companion app to sign in and register…';
1279
+ }
1280
+ const denied = devices.find((device) => device.permission_status === 'denied');
1281
+ if (denied)
1282
+ return `Waiting for notifications to be allowed on ${denied.display_name}…`;
1283
+ const undecided = devices.find((device) => device.permission_status === 'not_determined');
1284
+ if (undecided)
1285
+ return `Waiting for ${undecided.display_name} to allow the notification prompt…`;
1286
+ return 'Waiting for a companion device to become ready…';
1287
+ }
1288
+ /**
1289
+ * Observe the supported Device Installation path while the user finishes the
1290
+ * app-side work. There is deliberately no invented QR or download URL here:
1291
+ * this release exposes neither an App Store/TestFlight URL nor a device-pairing
1292
+ * endpoint, and a placeholder bridge is worse than an explicit boundary.
1293
+ */
1294
+ async function waitForReadyDevice(deps, state) {
1295
+ const remedy = state.remedy;
1296
+ if (deps.io.interactive !== true || remedy?.by !== 'user-elsewhere')
1297
+ return 'pending';
1298
+ await deps.io.note?.(`${state.detail}\n${remedy.summary}`, 'Finish setup on your companion device');
1299
+ if (!(await deps.io.confirm('Wait here while you finish that on your device?', true))) {
1300
+ return 'pending';
1301
+ }
1302
+ const config = loadConfig({ cwd: deps.cwd, env: deps.env });
1303
+ const authed = authedClient(deps, config);
1304
+ if (!authed)
1305
+ return 'failed';
1306
+ const now = deps.now ?? Date.now;
1307
+ const sleep = deps.sleep ?? ((milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)));
1308
+ const deadline = now() + DEVICE_BRIDGE_TIMEOUT_MS;
1309
+ const spinner = await deps.io.spinner?.('Waiting for a companion device…');
1310
+ let lastDevices = [];
1311
+ while (now() < deadline) {
1312
+ try {
1313
+ const response = await authed.client.listDevices();
1314
+ lastDevices = response.devices;
1315
+ const ready = response.devices.find(deviceCanReceive);
1316
+ if (ready) {
1317
+ spinner?.stop(`${ready.display_name} is ready to receive`);
1318
+ return 'closed';
1319
+ }
1320
+ spinner?.message(deviceBridgeMessage(response.devices));
1321
+ }
1322
+ catch (err) {
1323
+ if (!(err instanceof NetworkError)) {
1324
+ spinner?.error('Could not check companion readiness');
1325
+ reportError(deps, err);
1326
+ return 'failed';
1327
+ }
1328
+ spinner?.message('Connection lost — still watching…');
1329
+ }
1330
+ await sleep(Math.min(DEVICE_BRIDGE_POLL_MS, Math.max(0, deadline - now())));
1331
+ }
1332
+ spinner?.error('No companion device became ready');
1333
+ deps.io.err(deviceBridgeMessage(lastDevices).replace(/…$/, '.'));
1334
+ return 'pending';
1335
+ }
1336
+ function setupProofDraft(config, device) {
1337
+ const project = config.project.value;
1338
+ return buildDraft(config, {
1339
+ title: 'NotifAI is ready',
1340
+ body: project === null
1341
+ ? 'This real notification completed setup verification.'
1342
+ : `This real notification completed setup verification for ${project}.`,
1343
+ event: 'setup_verified',
1344
+ kind: 'update',
1345
+ platform: 'ios',
1346
+ device: [device.device_id],
1347
+ sound: 'none',
1348
+ level: 'passive',
1349
+ collapseKey: 'notifai-setup-verification',
1350
+ });
1351
+ }
1352
+ async function submitSetupProof(deps, client, config, device) {
1353
+ const build = setupProofDraft(config, device);
1354
+ if (!build.ok) {
1355
+ deps.io.err(`Could not build the setup verification notification: ${build.error}`);
1356
+ return null;
1357
+ }
1358
+ const capabilities = CAPABILITIES_V1.describe(build.platform);
1359
+ if (!capabilities) {
1360
+ deps.io.err(`No capability contract is available for ${build.platform}.`);
1361
+ return null;
1362
+ }
1363
+ const validation = validateDraft(build.draft, capabilities);
1364
+ if (!validation.ok) {
1365
+ for (const issue of validation.errors) {
1366
+ deps.io.err(`Setup verification ${issue.path}: ${issue.message}`);
1367
+ }
1368
+ return null;
1369
+ }
1370
+ try {
1371
+ return await client.submit({
1372
+ idempotency_key: `init-${randomBytes(12).toString('base64url')}`,
1373
+ draft: build.draft,
1374
+ }, config.wait_seconds.value);
1375
+ }
1376
+ catch (err) {
1377
+ reportError(deps, err);
1378
+ return null;
1379
+ }
1380
+ }
1381
+ /**
1382
+ * Send or resume one real setup probe, then wait for a Companion Receipt.
1383
+ * Provider Acceptance is intentionally insufficient: it proves APNs accepted
1384
+ * the push, not that a companion process received it.
1385
+ */
1386
+ async function runSetupProof(deps) {
1387
+ const config = loadConfig({ cwd: deps.cwd, env: deps.env });
1388
+ const authed = authedClient(deps, config);
1389
+ if (!authed)
1390
+ return 'failed';
1391
+ let devices;
1392
+ try {
1393
+ devices = (await authed.client.listDevices()).devices;
1394
+ }
1395
+ catch (err) {
1396
+ reportError(deps, err);
1397
+ return 'failed';
1398
+ }
1399
+ const candidates = readyIosDevices(devices);
1400
+ const existing = readSetupProof(deps);
1401
+ const target = candidates.find((device) => device.device_id === existing?.device_id && existing.project === config.project.value) ?? candidates[0];
1402
+ if (!target) {
1403
+ deps.io.err('Setup proof needs a receipt-capable iPhone. The current macOS notification path does not emit Companion Receipts.');
1404
+ return 'pending';
1405
+ }
1406
+ let proof = existing?.device_id === target.device_id && existing.project === config.project.value
1407
+ ? existing
1408
+ : null;
1409
+ if (proof === null) {
1410
+ const receipt = await submitSetupProof(deps, authed.client, config, target);
1411
+ if (receipt === null)
1412
+ return 'failed';
1413
+ if (receipt.overall === 'provider_rejected_all') {
1414
+ deps.io.err(formatReceipt(receipt));
1415
+ return 'failed';
1416
+ }
1417
+ proof = {
1418
+ request_id: receipt.request_id,
1419
+ device_id: target.device_id,
1420
+ project: config.project.value,
1421
+ started_at: new Date((deps.now ?? Date.now)()).toISOString(),
1422
+ };
1423
+ if (!writeSetupProof(deps, proof))
1424
+ return 'failed';
1425
+ deps.io.out(`Verification notification sent to ${target.display_name} (${proof.request_id}).`);
1426
+ }
1427
+ else {
1428
+ deps.io.out(`Checking verification notification ${proof.request_id} again.`);
1429
+ }
1430
+ const now = deps.now ?? Date.now;
1431
+ const sleep = deps.sleep ?? ((milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)));
1432
+ const deadline = now() + PROOF_TIMEOUT_MS;
1433
+ const spinner = deps.io.interactive === true
1434
+ ? await deps.io.spinner?.('Waiting for a Companion Receipt…')
1435
+ : null;
1436
+ let lastError = null;
1437
+ let replacedMissingProof = false;
1438
+ for (;;) {
1439
+ try {
1440
+ const snapshot = await authed.client.evidence(proof.request_id);
1441
+ const observed = observedCompanionReceipt(snapshot, proof.device_id);
1442
+ if (observed) {
1443
+ spinner?.stop(`Receipt observed from ${observed.delivery.device_name}`);
1444
+ return 'closed';
1445
+ }
1446
+ lastError = null;
1447
+ }
1448
+ catch (err) {
1449
+ lastError = err;
1450
+ if (err instanceof ApiCallError &&
1451
+ err.code === 'not_found' &&
1452
+ !replacedMissingProof) {
1453
+ const receipt = await submitSetupProof(deps, authed.client, config, target);
1454
+ if (receipt === null)
1455
+ return 'failed';
1456
+ if (receipt.overall === 'provider_rejected_all') {
1457
+ deps.io.err(formatReceipt(receipt));
1458
+ return 'failed';
1459
+ }
1460
+ proof = {
1461
+ request_id: receipt.request_id,
1462
+ device_id: target.device_id,
1463
+ project: config.project.value,
1464
+ started_at: new Date(now()).toISOString(),
1465
+ };
1466
+ if (!writeSetupProof(deps, proof))
1467
+ return 'failed';
1468
+ replacedMissingProof = true;
1469
+ lastError = null;
1470
+ deps.io.out(`The saved proof had expired; sent replacement ${proof.request_id}.`);
1471
+ continue;
1472
+ }
1473
+ if (!(err instanceof NetworkError)) {
1474
+ spinner?.error('Could not read Companion Receipt evidence');
1475
+ reportError(deps, err);
1476
+ return 'failed';
1477
+ }
1478
+ spinner?.message('Connection lost — still checking the same request…');
1479
+ }
1480
+ if (now() >= deadline)
1481
+ break;
1482
+ await sleep(Math.min(PROOF_POLL_MS, Math.max(0, deadline - now())));
1483
+ }
1484
+ spinner?.error('Companion Receipt not observed yet');
1485
+ if (lastError instanceof NetworkError)
1486
+ deps.io.err(lastError.message);
1487
+ deps.io.err(`No Companion Receipt was observed for ${proof.request_id} within ${PROOF_TIMEOUT_MS / 1000}s. ` +
1488
+ 'That is not proof of non-receipt; re-run `notifai init` to check this same notification again.');
1489
+ return 'pending';
1215
1490
  }
1216
1491
  /** Whether an optional gap should be closed, given flags and who is watching. */
1217
1492
  function wantsOptional(deps, state, flags) {
@@ -1250,41 +1525,99 @@ function wantsOptional(deps, state, flags) {
1250
1525
  */
1251
1526
  export async function initCommand(deps, flags) {
1252
1527
  await deps.io.intro?.('NotifAI setup');
1253
- if (SKILLS_SOURCE === '' && flags.skills === true) {
1254
- deps.io.err('The optional agent skill is not published yet; this build has no skill source configured.');
1255
- return EXIT.failed;
1256
- }
1257
1528
  let readiness = await assessReadiness(deps);
1258
1529
  let failed = false;
1259
- // One pass is enough: closing a gap never opens an earlier one, and the
1260
- // states that remain are exactly those needing someone who is not the CLI.
1261
- for (const state of readiness.states) {
1262
- const remedy = state.remedy;
1263
- if (remedy === undefined)
1264
- continue;
1265
- // Its to launch, theirs to complete. Offering to start the sign-in is the
1266
- // single most useful thing this command does for a person, so it is worth
1267
- // the extra branch — but only when someone is actually watching, since an
1268
- // agent that reached a browser prompt would simply hang.
1269
- if (remedy.by === 'user-here' && remedy.interactive === true) {
1270
- if (state.status !== 'gap' || deps.io.interactive !== true)
1530
+ const attempted = new Set();
1531
+ // Re-assess after every successful action. This is how a browser approval or
1532
+ // companion registration can unlock the next state while the user is still
1533
+ // here, without copying the dependency graph into a second setup script.
1534
+ for (;;) {
1535
+ let advanced = false;
1536
+ let stop = false;
1537
+ for (const state of readiness.states) {
1538
+ if (state.status === 'ready')
1271
1539
  continue;
1272
- if (!(await deps.io.confirm('Sign in now? (opens your browser)', true)))
1540
+ if (state.status === 'unknown') {
1541
+ stop = true;
1542
+ break;
1543
+ }
1544
+ const remedy = state.remedy;
1545
+ if (remedy === undefined || attempted.has(state.id)) {
1546
+ if (state.status === 'gap')
1547
+ stop = true;
1548
+ if (stop)
1549
+ break;
1273
1550
  continue;
1274
- if ((await loginCommand(deps, {})) !== EXIT.ok)
1275
- failed = true;
1276
- continue;
1277
- }
1278
- if (remedy.by !== 'cli')
1279
- continue;
1280
- if (state.status === 'gap') {
1281
- if (!(await closeGap(deps, state, flags)))
1282
- failed = true;
1283
- }
1284
- else if (state.status === 'optional-gap' && (await wantsOptional(deps, state, flags))) {
1285
- if (!(await closeGap(deps, state, flags)))
1286
- failed = true;
1551
+ }
1552
+ if (state.status === 'optional-gap') {
1553
+ if (remedy.by !== 'cli' || !(await wantsOptional(deps, state, flags)))
1554
+ continue;
1555
+ attempted.add(state.id);
1556
+ const result = await closeGap(deps, state, flags);
1557
+ if (result === 'failed')
1558
+ failed = true;
1559
+ if (result !== 'closed') {
1560
+ stop = true;
1561
+ break;
1562
+ }
1563
+ readiness = await assessReadiness(deps);
1564
+ advanced = true;
1565
+ break;
1566
+ }
1567
+ if (remedy.by === 'cli') {
1568
+ attempted.add(state.id);
1569
+ const result = await closeGap(deps, state, flags);
1570
+ if (result === 'failed')
1571
+ failed = true;
1572
+ if (result !== 'closed') {
1573
+ stop = true;
1574
+ break;
1575
+ }
1576
+ readiness = await assessReadiness(deps);
1577
+ advanced = true;
1578
+ break;
1579
+ }
1580
+ // Its to launch, theirs to complete. Offering to start the sign-in is
1581
+ // useful only when someone is demonstrably watching; an agent never
1582
+ // reaches this prompt or opens a browser.
1583
+ if (remedy.by === 'user-here' &&
1584
+ remedy.interactive === true &&
1585
+ deps.io.interactive === true) {
1586
+ attempted.add(state.id);
1587
+ if (!(await deps.io.confirm('Sign in now? (opens your browser)', true))) {
1588
+ stop = true;
1589
+ break;
1590
+ }
1591
+ if ((await loginCommand(deps, {})) !== EXIT.ok) {
1592
+ failed = true;
1593
+ stop = true;
1594
+ break;
1595
+ }
1596
+ readiness = await assessReadiness(deps);
1597
+ advanced = true;
1598
+ break;
1599
+ }
1600
+ if (state.id === 'devices' &&
1601
+ remedy.by === 'user-elsewhere' &&
1602
+ deps.io.interactive === true) {
1603
+ attempted.add(state.id);
1604
+ const result = await waitForReadyDevice(deps, state);
1605
+ if (result === 'failed')
1606
+ failed = true;
1607
+ if (result !== 'closed') {
1608
+ stop = true;
1609
+ break;
1610
+ }
1611
+ readiness = await assessReadiness(deps);
1612
+ advanced = true;
1613
+ break;
1614
+ }
1615
+ // A human-only remedy is the first blocker for an unattended agent.
1616
+ stop = true;
1617
+ break;
1287
1618
  }
1619
+ if (stop || !advanced)
1620
+ break;
1288
1621
  }
1289
1622
  readiness = await assessReadiness(deps);
1290
1623
  for (const state of readiness.states.filter((s) => s.status === 'ready')) {
@@ -1309,7 +1642,9 @@ export async function initCommand(deps, flags) {
1309
1642
  deps.io.out(' Then re-run `notifai init` and it will pick up from here.');
1310
1643
  }
1311
1644
  await deps.io.outro?.('One step remains (above)');
1312
- return failed ? EXIT.failed : EXIT.ok;
1645
+ // An agent must be able to branch on setup being blocked without parsing
1646
+ // prose. A present human may deliberately leave and resume later.
1647
+ return failed || deps.io.interactive !== true ? EXIT.failed : EXIT.ok;
1313
1648
  }
1314
1649
  // ---------------------------------------------------------------------------
1315
1650
  // doctor
@@ -1365,6 +1700,8 @@ async function contractCheck(client) {
1365
1700
  export async function assessReadiness(deps) {
1366
1701
  const config = loadConfig({ cwd: deps.cwd, env: deps.env });
1367
1702
  const states = [];
1703
+ let accountClient = null;
1704
+ let accountDevices = null;
1368
1705
  const configPath = path.join(deps.cwd, '.notifai', 'config.toml');
1369
1706
  const projectSlug = config.project.value;
1370
1707
  states.push(projectSlug !== null
@@ -1464,9 +1801,11 @@ export async function assessReadiness(deps) {
1464
1801
  }
1465
1802
  else {
1466
1803
  const client = makeClient(deps, baseUrl, `Bearer nfm_${credential.machineId}.${credential.secret}`);
1804
+ accountClient = client;
1467
1805
  try {
1468
1806
  const { devices } = await client.listDevices();
1469
- const ready = devices.filter((d) => d.registration_healthy);
1807
+ accountDevices = devices;
1808
+ const ready = devices.filter(deviceCanReceive);
1470
1809
  states.push({
1471
1810
  id: 'auth',
1472
1811
  title: 'Account',
@@ -1490,13 +1829,15 @@ export async function assessReadiness(deps) {
1490
1829
  // useless advice to someone who installed it and denied the
1491
1830
  // permission prompt.
1492
1831
  detail: devices.length === 0
1493
- ? 'nothing registered yet'
1832
+ ? 'nothing registered yet; this release has no supported App Store/TestFlight URL for the CLI to open'
1494
1833
  : `${devices.map((d) => `${d.display_name} (${d.permission_status})`).join(', ')} — registered but not able to receive`,
1495
1834
  remedy: {
1496
1835
  by: 'user-elsewhere',
1497
1836
  summary: devices.length === 0
1498
- ? 'install the companion app on your phone, sign in with the same account, and allow notifications'
1499
- : 'allow notifications for NotifAI in that device’s system settings',
1837
+ ? 'install NotifAI through a supported distribution you already have, open it, sign in with the same account, and allow notifications'
1838
+ : devices.some((d) => d.permission_status === 'denied')
1839
+ ? 'allow notifications for NotifAI in that device’s system settings'
1840
+ : 'open NotifAI on that device and allow its notification prompt',
1500
1841
  },
1501
1842
  });
1502
1843
  }
@@ -1518,28 +1859,102 @@ export async function assessReadiness(deps) {
1518
1859
  }
1519
1860
  }
1520
1861
  states.push(...hookStates(deps));
1521
- states.push(SKILLS_SOURCE === ''
1522
- ? {
1523
- id: 'skill',
1524
- title: 'Agent guidance skill',
1525
- // Nothing outstanding, because nothing can be done: this build has
1526
- // no published skill source configured. Silence here would read as
1527
- // an omission to anyone who had seen it mentioned in the docs.
1528
- status: 'ready',
1529
- detail: 'not applicable — this build has no published skill source configured',
1862
+ states.push({
1863
+ id: 'skill',
1864
+ title: 'Agent guidance skill',
1865
+ status: 'optional-gap',
1866
+ detail: `not installed from ${SKILLS_SOURCE} in this project`,
1867
+ remedy: {
1868
+ by: 'cli',
1869
+ summary: 'install the skill agents follow when deciding to notify',
1870
+ command: 'notifai init --skills',
1871
+ },
1872
+ });
1873
+ states.push(await setupProofState(deps, config, accountClient, accountDevices));
1874
+ return { states };
1875
+ }
1876
+ async function setupProofState(deps, config, client, devices) {
1877
+ if (client === null || devices === null) {
1878
+ return {
1879
+ id: 'proof',
1880
+ title: 'Delivery proof',
1881
+ status: 'unknown',
1882
+ detail: 'not checked — account and device readiness must be established first',
1883
+ };
1884
+ }
1885
+ const ready = devices.filter(deviceCanReceive);
1886
+ if (ready.length === 0) {
1887
+ return {
1888
+ id: 'proof',
1889
+ title: 'Delivery proof',
1890
+ status: 'unknown',
1891
+ detail: 'not checked — no companion device is ready',
1892
+ };
1893
+ }
1894
+ const ios = readyIosDevices(devices);
1895
+ if (ios.length === 0) {
1896
+ return {
1897
+ id: 'proof',
1898
+ title: 'Delivery proof',
1899
+ status: 'gap',
1900
+ detail: 'blocked — the current macOS notification path does not emit Companion Receipts, so this CLI cannot prove receipt on a macOS-only setup',
1901
+ remedy: {
1902
+ by: 'user-elsewhere',
1903
+ summary: 'pair a receipt-capable iPhone build; no supported macOS receipt bridge is available in this release',
1904
+ },
1905
+ };
1906
+ }
1907
+ const proof = readSetupProof(deps);
1908
+ const target = proof === null ? null : ios.find((device) => device.device_id === proof.device_id);
1909
+ if (proof === null || proof.project !== config.project.value || target === undefined) {
1910
+ return {
1911
+ id: 'proof',
1912
+ title: 'Delivery proof',
1913
+ status: 'gap',
1914
+ detail: 'no Companion Receipt has proven this project on this machine yet',
1915
+ remedy: {
1916
+ by: 'cli',
1917
+ summary: 'send one real verification notification and wait for its Companion Receipt',
1918
+ command: 'notifai init',
1919
+ },
1920
+ };
1921
+ }
1922
+ try {
1923
+ const snapshot = await client.evidence(proof.request_id);
1924
+ const observed = observedCompanionReceipt(snapshot, proof.device_id);
1925
+ if (observed) {
1926
+ return {
1927
+ id: 'proof',
1928
+ title: 'Delivery proof',
1929
+ status: 'ready',
1930
+ detail: `Companion Receipt observed from ${observed.delivery.device_name} at ${observed.observedAt} (${proof.request_id})`,
1931
+ };
1530
1932
  }
1531
- : {
1532
- id: 'skill',
1533
- title: 'Agent guidance skill',
1534
- status: 'optional-gap',
1535
- detail: 'not installed in this project',
1933
+ return {
1934
+ id: 'proof',
1935
+ title: 'Delivery proof',
1936
+ status: 'gap',
1937
+ detail: `${proof.request_id} was sent, but its Companion Receipt is still unknown`,
1536
1938
  remedy: {
1537
1939
  by: 'cli',
1538
- summary: 'install the skill agents follow when deciding to notify',
1539
- command: 'notifai init --skills',
1940
+ summary: 'check the same verification notification again',
1941
+ command: 'notifai init',
1540
1942
  },
1541
- });
1542
- return { states };
1943
+ };
1944
+ }
1945
+ catch (err) {
1946
+ return {
1947
+ id: 'proof',
1948
+ title: 'Delivery proof',
1949
+ status: 'gap',
1950
+ detail: `could not read ${proof.request_id} evidence (${err instanceof ApiCallError ? err.code : String(err)})`,
1951
+ remedy: {
1952
+ by: 'cli',
1953
+ summary: 'retry the existing verification evidence check',
1954
+ command: 'notifai init',
1955
+ },
1956
+ };
1957
+ }
1543
1958
  }
1544
1959
  export async function doctorCommand(deps, flags) {
1545
1960
  const readiness = await assessReadiness(deps);