@ouro.bot/cli 0.1.0-alpha.723 → 0.1.0-alpha.725

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.
Files changed (55) hide show
  1. package/changelog.json +295 -275
  2. package/dist/heart/active-work.js +10 -3
  3. package/dist/heart/core.js +343 -77
  4. package/dist/heart/daemon/cli-exec.js +132 -15
  5. package/dist/heart/daemon/cli-help.js +13 -2
  6. package/dist/heart/daemon/cli-parse.js +82 -1
  7. package/dist/heart/daemon/daemon.js +286 -33
  8. package/dist/heart/daemon/doctor.js +82 -6
  9. package/dist/heart/daemon/habit-private-wake.js +6 -1
  10. package/dist/heart/habits/habit-cancel.js +810 -0
  11. package/dist/heart/habits/habit-lifecycle.js +1327 -0
  12. package/dist/heart/habits/habit-migration.js +28 -23
  13. package/dist/heart/habits/habit-parser.js +72 -8
  14. package/dist/heart/habits/habit-runtime-state.js +3 -24
  15. package/dist/heart/habits/habit-scheduler.js +268 -50
  16. package/dist/heart/habits/habit-session.js +16 -0
  17. package/dist/heart/hatch/hatch-flow.js +6 -4
  18. package/dist/heart/mailbox/readers/runtime-readers.js +26 -27
  19. package/dist/heart/orientation-frame.js +58 -1
  20. package/dist/heart/provider-attempt.js +4 -1
  21. package/dist/heart/providers/anthropic.js +23 -2
  22. package/dist/heart/start-of-turn-packet.js +14 -11
  23. package/dist/heart/streaming.js +565 -185
  24. package/dist/mind/obligation-steering.js +15 -9
  25. package/dist/mind/prompt.js +58 -26
  26. package/dist/nerves/coverage/run-artifacts.js +55 -35
  27. package/dist/nerves/review/cli.js +2 -2
  28. package/dist/nerves/review/core.js +14 -7
  29. package/dist/repertoire/tools-base.js +2 -0
  30. package/dist/repertoire/tools-habits.js +103 -0
  31. package/dist/repertoire/tools.js +18 -3
  32. package/dist/rsvp/cli.js +126 -20
  33. package/dist/rsvp/diagnostics.js +12 -2
  34. package/dist/rsvp/habit-policy.js +7 -0
  35. package/dist/rsvp/habit-stage.js +19 -4
  36. package/dist/rsvp/native-habit-runner.js +151 -11
  37. package/dist/rsvp/outbound-state.js +360 -0
  38. package/dist/senses/bluebubbles/client.js +90 -17
  39. package/dist/senses/bluebubbles/inbound-log.js +1 -0
  40. package/dist/senses/bluebubbles/index.js +662 -425
  41. package/dist/senses/bluebubbles/model.js +57 -13
  42. package/dist/senses/bluebubbles/mutation-log.js +21 -10
  43. package/dist/senses/bluebubbles/reaction-policy.js +55 -0
  44. package/dist/senses/bluebubbles/semantic-receipts.js +1818 -0
  45. package/dist/senses/cli/tui-store.js +1 -0
  46. package/dist/senses/cli.js +1 -0
  47. package/dist/senses/habit-lifecycle-guard.js +24 -0
  48. package/dist/senses/ingress-evidence.js +2 -0
  49. package/dist/senses/mail.js +129 -0
  50. package/dist/senses/pipeline.js +90 -24
  51. package/dist/senses/private-runtime-worker.js +21 -20
  52. package/dist/senses/private-runtime.js +79 -29
  53. package/dist/senses/shared-turn.js +1 -0
  54. package/dist/senses/teams.js +1 -0
  55. package/package.json +6 -1
@@ -488,6 +488,13 @@ function isValidHabitPokeCommand(command) {
488
488
  && typeof command.habitName === "string"
489
489
  && command.habitName.trim().length > 0;
490
490
  }
491
+ function isValidHabitProbeCommand(command) {
492
+ return typeof command.agent === "string"
493
+ && command.agent.trim().length > 0
494
+ && typeof command.habitName === "string"
495
+ && command.habitName.trim().length > 0
496
+ && (command.noSend === undefined || command.noSend === true);
497
+ }
491
498
  function taskIdFromTaskPokePrivateWakeCommand(command) {
492
499
  if (command.kind !== "private.wake" || command.triggerSource !== "task-poke")
493
500
  return null;
@@ -1162,6 +1169,10 @@ class OuroDaemon {
1162
1169
  originRefs: [{ kind: "daemon-command", id: "inner.wake" }],
1163
1170
  };
1164
1171
  }
1172
+ const hasNoSendCapability = command.originRefs?.some((ref) => ref.kind === "capability" && ref.id === "no-send") ?? false;
1173
+ if ((command.noSend === true) !== hasNoSendCapability) {
1174
+ throw new Error("private-runtime no-send capability does not match its bound origin reference");
1175
+ }
1165
1176
  return {
1166
1177
  agent: command.agent,
1167
1178
  origin: "daemon.private.wake",
@@ -1210,37 +1221,110 @@ class OuroDaemon {
1210
1221
  ],
1211
1222
  };
1212
1223
  }
1213
- resolveHabitPokeOccurrenceId(command, trigger) {
1214
- const agentRoot = path.join(this.bundlesRoot, `${command.agent}.ouro`);
1215
- const habitPath = path.join(agentRoot, "habits", `${command.habitName}.md`);
1216
- let parsedHabit = null;
1217
- if ((0, habit_policy_1.isRsvpHabitName)(command.habitName)) {
1218
- if (!fs.existsSync(habitPath))
1219
- return { skipReason: "RSVP habit file not found" };
1224
+ resolveHabitDispatch(options) {
1225
+ const agentRoot = path.join(this.bundlesRoot, `${options.agent}.ouro`);
1226
+ const habitPath = path.join(agentRoot, "habits", `${options.habitName}.md`);
1227
+ const isRsvp = (0, habit_policy_1.isRsvpHabitName)(options.habitName);
1228
+ let readFailure = null;
1229
+ let habit;
1230
+ if (!fs.existsSync(habitPath)) {
1231
+ readFailure = "missing";
1232
+ habit = (0, habit_parser_1.createDegradedHabitFile)(habitPath, "read_error", "", "habit file not found");
1233
+ }
1234
+ else {
1220
1235
  try {
1221
- parsedHabit = (0, habit_runtime_state_1.applyHabitRuntimeState)(agentRoot, (0, habit_parser_1.parseHabitFile)(fs.readFileSync(habitPath, "utf-8"), habitPath));
1236
+ habit = (0, habit_runtime_state_1.applyHabitRuntimeState)(agentRoot, (0, habit_parser_1.parseHabitFile)(fs.readFileSync(habitPath, "utf-8"), habitPath));
1222
1237
  }
1223
1238
  catch (error) {
1224
- return { skipReason: `RSVP habit metadata invalid: ${messageFromHabitPokeError(error)}` };
1239
+ readFailure = "read";
1240
+ habit = (0, habit_parser_1.createDegradedHabitFile)(habitPath, "read_error", "", messageFromHabitPokeError(error));
1225
1241
  }
1226
- if (!parsedHabit.rsvp)
1227
- return { skipReason: "RSVP habit metadata is required before private runtime wake" };
1228
1242
  }
1229
- if (trigger === "poke" || trigger === "manual")
1230
- return undefined;
1231
- if (typeof command.occurrenceId === "string" && command.occurrenceId.trim().length > 0)
1232
- return command.occurrenceId.trim();
1233
- if (!fs.existsSync(habitPath))
1234
- return { skipReason: "habit file not found" };
1235
- const habit = parsedHabit ?? (0, habit_runtime_state_1.applyHabitRuntimeState)(agentRoot, (0, habit_parser_1.parseHabitFile)(fs.readFileSync(habitPath, "utf-8"), habitPath));
1236
- if (habit.cadence === null)
1237
- return { skipReason: "habit has no cadence" };
1238
- const cadence = habit.cadence;
1239
- if (habit.lastRun === null)
1240
- return `${trigger}:first-run:${cadence}`;
1241
- return `${trigger}:last-run:${habit.lastRun}:cadence:${cadence}`;
1243
+ if (habit.status !== "active") {
1244
+ let skipReason = `habit status ${habit.status} is non-executable`;
1245
+ if (isRsvp && habit.status === "degraded") {
1246
+ if (readFailure === "missing") {
1247
+ skipReason = "RSVP habit file not found";
1248
+ }
1249
+ else {
1250
+ skipReason = `RSVP habit metadata invalid: ${habit.degradedDetail ?? habit.degradedReason}`;
1251
+ }
1252
+ }
1253
+ else if (readFailure === "missing"
1254
+ && options.source === "habit-poke"
1255
+ && options.trigger !== "manual"
1256
+ && options.trigger !== "poke") {
1257
+ skipReason = "habit file not found";
1258
+ }
1259
+ return { kind: "skipped", habit, skipReason };
1260
+ }
1261
+ if (isRsvp && !habit.rsvp) {
1262
+ return {
1263
+ kind: "skipped",
1264
+ habit,
1265
+ skipReason: "RSVP habit metadata is required before private runtime wake",
1266
+ };
1267
+ }
1268
+ if (options.trigger === "poke" || options.trigger === "manual") {
1269
+ return { kind: "ready", habit };
1270
+ }
1271
+ if (typeof options.occurrenceId === "string" && options.occurrenceId.trim().length > 0) {
1272
+ return { kind: "ready", habit, occurrenceId: options.occurrenceId.trim() };
1273
+ }
1274
+ if (habit.cadence === null) {
1275
+ return { kind: "skipped", habit, skipReason: "habit has no cadence" };
1276
+ }
1277
+ if (habit.lastRun === null) {
1278
+ return { kind: "ready", habit, occurrenceId: `${options.trigger}:first-run:${habit.cadence}` };
1279
+ }
1280
+ return {
1281
+ kind: "ready",
1282
+ habit,
1283
+ occurrenceId: `${options.trigger}:last-run:${habit.lastRun}:cadence:${habit.cadence}`,
1284
+ };
1285
+ }
1286
+ emitHabitDispatchSkipped(options) {
1287
+ (0, runtime_1.emitNervesEvent)({
1288
+ level: "warn",
1289
+ component: "daemon",
1290
+ event: "daemon.habit_dispatch_skipped",
1291
+ message: "habit dispatch rejected by current lifecycle state",
1292
+ meta: {
1293
+ agent: options.agent,
1294
+ habitName: options.habitName,
1295
+ trigger: options.trigger,
1296
+ status: options.resolution.habit.status,
1297
+ degradedReason: options.resolution.habit.status === "degraded"
1298
+ ? options.resolution.habit.degradedReason
1299
+ : null,
1300
+ detail: options.resolution.habit.status === "degraded"
1301
+ ? options.resolution.habit.degradedDetail
1302
+ : null,
1303
+ reason: options.resolution.skipReason,
1304
+ },
1305
+ });
1242
1306
  }
1243
- async runNativeRsvpHabit(command, trigger, occurrenceId) {
1307
+ emitHabitDispatchReady(options) {
1308
+ (0, runtime_1.emitNervesEvent)({
1309
+ component: "daemon",
1310
+ event: "daemon.habit_dispatch_ready",
1311
+ message: "habit dispatch accepted by current lifecycle state",
1312
+ meta: {
1313
+ agent: options.agent,
1314
+ habitName: options.habitName,
1315
+ trigger: options.trigger,
1316
+ status: options.resolution.habit.status,
1317
+ occurrenceId: options.resolution.occurrenceId ?? null,
1318
+ },
1319
+ });
1320
+ }
1321
+ skippedHabitDispatchResponse(options) {
1322
+ return {
1323
+ ok: true,
1324
+ message: `skipped scheduled habit ${options.habitName} for ${options.agent}: ${options.skipReason}`,
1325
+ };
1326
+ }
1327
+ async runNativeRsvpHabit(command, trigger, occurrenceId, noSend = false) {
1244
1328
  const runner = this.rsvpHabitRunner ?? (async (input) => {
1245
1329
  const { runNativeRsvpHabit } = await Promise.resolve().then(() => __importStar(require("../../rsvp/native-habit-runner")));
1246
1330
  return runNativeRsvpHabit(input);
@@ -1251,7 +1335,48 @@ class OuroDaemon {
1251
1335
  habitName: command.habitName,
1252
1336
  trigger,
1253
1337
  ...(occurrenceId ? { occurrenceId } : {}),
1338
+ ...(noSend ? { noSend: true } : {}),
1254
1339
  });
1340
+ if (noSend) {
1341
+ const payload = result.payload;
1342
+ const status = payload?.status;
1343
+ const transportInvocationCount = payload?.transportInvocationCount;
1344
+ const contractError = "native RSVP habit probe violated the immutable no-send result contract";
1345
+ if (!result.ok) {
1346
+ return {
1347
+ ok: false,
1348
+ error: result.message,
1349
+ data: {
1350
+ ok: false,
1351
+ noSend: true,
1352
+ status: typeof status === "string" ? status : "degraded",
1353
+ transportInvocationCount: null,
1354
+ error: result.message,
1355
+ },
1356
+ };
1357
+ }
1358
+ if (payload?.noSend !== true
1359
+ || typeof status !== "string"
1360
+ || typeof transportInvocationCount !== "number"
1361
+ || transportInvocationCount !== 0) {
1362
+ return {
1363
+ ok: false,
1364
+ error: contractError,
1365
+ data: {
1366
+ ok: false,
1367
+ noSend: true,
1368
+ status: typeof status === "string" ? status : "degraded",
1369
+ transportInvocationCount: null,
1370
+ error: contractError,
1371
+ },
1372
+ };
1373
+ }
1374
+ return {
1375
+ ok: true,
1376
+ message: result.message,
1377
+ data: { noSend: true, status, transportInvocationCount: 0 },
1378
+ };
1379
+ }
1255
1380
  return {
1256
1381
  ok: result.ok,
1257
1382
  message: result.message,
@@ -1281,6 +1406,7 @@ class OuroDaemon {
1281
1406
  type: "habit",
1282
1407
  habitName: habitMessage.habitName,
1283
1408
  trigger: habitMessage.trigger,
1409
+ ...(habitMessage.noSend ? { noSend: true } : {}),
1284
1410
  privateTurnDecision: decision,
1285
1411
  };
1286
1412
  }
@@ -1616,8 +1742,43 @@ class OuroDaemon {
1616
1742
  }
1617
1743
  case "inner.wake":
1618
1744
  return this.handlePrivateRuntimeWake(command);
1619
- case "private.wake":
1745
+ case "private.wake": {
1746
+ const scheduledHabit = (0, habit_private_wake_1.habitMessageFromPrivateWakeCommand)(command);
1747
+ if (!scheduledHabit || !this.hasManagedPrivateRuntime(command.agent)) {
1748
+ return this.handlePrivateRuntimeWake(command);
1749
+ }
1750
+ const occurrenceRef = command.originRefs?.find((ref) => ref.kind === "habit-occurrence");
1751
+ const occurrenceId = typeof occurrenceRef?.id === "string" && occurrenceRef.id.trim().length > 0
1752
+ ? occurrenceRef.id.trim()
1753
+ : undefined;
1754
+ const resolution = this.resolveHabitDispatch({
1755
+ agent: command.agent,
1756
+ habitName: scheduledHabit.habitName,
1757
+ trigger: scheduledHabit.trigger,
1758
+ source: "private-wake",
1759
+ ...(occurrenceId ? { occurrenceId } : {}),
1760
+ });
1761
+ if (resolution.kind === "skipped") {
1762
+ this.emitHabitDispatchSkipped({
1763
+ agent: command.agent,
1764
+ habitName: scheduledHabit.habitName,
1765
+ trigger: scheduledHabit.trigger,
1766
+ resolution,
1767
+ });
1768
+ return this.skippedHabitDispatchResponse({
1769
+ agent: command.agent,
1770
+ habitName: scheduledHabit.habitName,
1771
+ skipReason: resolution.skipReason,
1772
+ });
1773
+ }
1774
+ this.emitHabitDispatchReady({
1775
+ agent: command.agent,
1776
+ habitName: scheduledHabit.habitName,
1777
+ trigger: scheduledHabit.trigger,
1778
+ resolution,
1779
+ });
1620
1780
  return this.handlePrivateRuntimeWake(command);
1781
+ }
1621
1782
  case "chat.connect":
1622
1783
  await this.processManager.startAgent(command.agent);
1623
1784
  return {
@@ -1663,23 +1824,115 @@ class OuroDaemon {
1663
1824
  error: `No managed agent '${command.agent}' is registered with daemon-managed private runtime.`,
1664
1825
  };
1665
1826
  }
1666
- const occurrenceId = this.resolveHabitPokeOccurrenceId(command, trigger);
1667
- if (typeof occurrenceId === "object") {
1827
+ const resolution = this.resolveHabitDispatch({
1828
+ agent: command.agent,
1829
+ habitName: command.habitName,
1830
+ trigger,
1831
+ source: "habit-poke",
1832
+ ...(command.occurrenceId ? { occurrenceId: command.occurrenceId } : {}),
1833
+ });
1834
+ if (resolution.kind === "skipped") {
1835
+ this.emitHabitDispatchSkipped({
1836
+ agent: command.agent,
1837
+ habitName: command.habitName,
1838
+ trigger,
1839
+ resolution,
1840
+ });
1841
+ return this.skippedHabitDispatchResponse({
1842
+ agent: command.agent,
1843
+ habitName: command.habitName,
1844
+ skipReason: resolution.skipReason,
1845
+ });
1846
+ }
1847
+ this.emitHabitDispatchReady({
1848
+ agent: command.agent,
1849
+ habitName: command.habitName,
1850
+ trigger,
1851
+ resolution,
1852
+ });
1853
+ if ((0, habit_policy_1.isRsvpHabitName)(command.habitName)) {
1854
+ return this.runNativeRsvpHabit(command, trigger, resolution.occurrenceId);
1855
+ }
1856
+ return this.handlePrivateRuntimeWake((0, habit_private_wake_1.buildHabitPrivateWakeCommand)({
1857
+ agent: command.agent,
1858
+ habitName: command.habitName,
1859
+ trigger,
1860
+ sourceRef: { kind: "daemon-command", id: "habit.poke" },
1861
+ occurrenceId: resolution.occurrenceId,
1862
+ }));
1863
+ }
1864
+ case "habit.probe": {
1865
+ if (!isValidHabitProbeCommand(command)) {
1866
+ return {
1867
+ ok: false,
1868
+ error: "Invalid habit.probe payload: expected non-empty agent/habitName and noSend=true.",
1869
+ };
1870
+ }
1871
+ const trigger = "manual";
1872
+ if (!(0, habit_policy_1.isRsvpHabitName)(command.habitName) && !this.hasManagedPrivateRuntime(command.agent)) {
1873
+ return {
1874
+ ok: false,
1875
+ error: `No managed agent '${command.agent}' is registered with daemon-managed private runtime.`,
1876
+ };
1877
+ }
1878
+ const resolution = this.resolveHabitDispatch({
1879
+ agent: command.agent,
1880
+ habitName: command.habitName,
1881
+ trigger,
1882
+ source: "habit-probe",
1883
+ });
1884
+ if (resolution.kind === "skipped") {
1885
+ this.emitHabitDispatchSkipped({
1886
+ agent: command.agent,
1887
+ habitName: command.habitName,
1888
+ trigger,
1889
+ resolution,
1890
+ });
1668
1891
  return {
1669
1892
  ok: true,
1670
- message: `skipped scheduled habit ${command.habitName} for ${command.agent}: ${occurrenceId.skipReason}`,
1893
+ message: `habit probe rejected by lifecycle: status=${resolution.habit.status}`,
1894
+ data: {
1895
+ noSend: true,
1896
+ status: resolution.habit.status,
1897
+ transportInvocationCount: 0,
1898
+ },
1671
1899
  };
1672
1900
  }
1901
+ this.emitHabitDispatchReady({
1902
+ agent: command.agent,
1903
+ habitName: command.habitName,
1904
+ trigger,
1905
+ resolution,
1906
+ });
1673
1907
  if ((0, habit_policy_1.isRsvpHabitName)(command.habitName)) {
1674
- return this.runNativeRsvpHabit(command, trigger, occurrenceId);
1908
+ return this.runNativeRsvpHabit(command, trigger, resolution.occurrenceId, true);
1675
1909
  }
1676
- return this.handlePrivateRuntimeWake((0, habit_private_wake_1.buildHabitPrivateWakeCommand)({
1910
+ const wake = await this.handlePrivateRuntimeWake((0, habit_private_wake_1.buildHabitPrivateWakeCommand)({
1677
1911
  agent: command.agent,
1678
1912
  habitName: command.habitName,
1679
1913
  trigger,
1680
- sourceRef: { kind: "daemon-command", id: "habit.poke" },
1681
- occurrenceId,
1914
+ sourceRef: { kind: "daemon-command", id: "habit.probe" },
1915
+ occurrenceId: resolution.occurrenceId,
1916
+ noSend: true,
1682
1917
  }));
1918
+ if (!wake.ok)
1919
+ return wake;
1920
+ const denied = wake.data
1921
+ ?.decision?.executable === false;
1922
+ const error = denied
1923
+ ? "non-RSVP habit probe was not executed because private-runtime policy denied execution"
1924
+ : "non-RSVP habit probe completion is asynchronous; zero-transport evidence is unverified";
1925
+ return {
1926
+ ok: false,
1927
+ error,
1928
+ data: {
1929
+ ok: false,
1930
+ noSend: true,
1931
+ status: resolution.habit.status,
1932
+ transportInvocationCount: null,
1933
+ error,
1934
+ },
1935
+ };
1683
1936
  }
1684
1937
  case "await.poke": {
1685
1938
  return this.handlePrivateRuntimeWake(this.buildAwaitPrivateWakeCommand(command));
@@ -99,6 +99,10 @@ function textField(record, key) {
99
99
  const value = record?.[key];
100
100
  return typeof value === "string" ? value.trim() : "";
101
101
  }
102
+ function stringArrayField(record, key) {
103
+ const value = record?.[key];
104
+ return Array.isArray(value) ? value.filter((entry) => typeof entry === "string") : [];
105
+ }
102
106
  function numberField(record, key, fallback) {
103
107
  const value = record?.[key];
104
108
  return typeof value === "number" && Number.isFinite(value) ? value : fallback;
@@ -271,6 +275,59 @@ function checkAgents(deps) {
271
275
  }
272
276
  return { name: "Agents", checks };
273
277
  }
278
+ /**
279
+ * Mail key coverage, as recorded by the mail sense.
280
+ *
281
+ * Doctor reads the sense's own per-cycle verdict rather than re-deriving it:
282
+ * a health check must need neither network access nor Azure credentials, and
283
+ * the registry that declares the key ids lives in a Blob container in hosted
284
+ * mode. The mail sense already fetches it every cycle, so its record is both
285
+ * the cheapest and the only honest source here.
286
+ *
287
+ * Absent is a `fail` because mail encrypted to a key with no private half is
288
+ * permanently unreadable — unlike an API key, ciphertext cannot be reissued.
289
+ * Anything the sense could not verify is a `warn`, never a `fail`: a locked or
290
+ * unavailable vault must not read as key loss.
291
+ */
292
+ function mailKeyCoverageCheck(deps, agentDir) {
293
+ const id = "mail.key_coverage";
294
+ const label = `${agentDir} mail key coverage`;
295
+ const statePath = `${deps.bundlesRoot}/${agentDir}/state/senses/mail/runtime.json`;
296
+ const unverified = (detail) => ({ id, label, status: "warn", detail });
297
+ if (!deps.existsSync(statePath)) {
298
+ return unverified(`no mail sense runtime state at ${statePath} — coverage is unverified until the mail sense runs a scan`);
299
+ }
300
+ let coverage;
301
+ try {
302
+ coverage = asRecord(JSON.parse(deps.readFileSync(statePath)).keyCoverage) ?? {};
303
+ }
304
+ catch {
305
+ return unverified(`mail sense runtime state at ${statePath} could not be read`);
306
+ }
307
+ const declaredKeyIds = stringArrayField(coverage, "declaredKeyIds");
308
+ const absentKeyIds = stringArrayField(coverage, "absentKeyIds");
309
+ const checkedAt = textField(coverage, "checkedAt");
310
+ const provenance = checkedAt ? ` (recorded ${checkedAt})` : "";
311
+ if (coverage.status === "covered") {
312
+ return {
313
+ id,
314
+ label,
315
+ status: "pass",
316
+ detail: declaredKeyIds.length === 1
317
+ ? `the 1 registry-declared mail key has a private half in the vault${provenance}`
318
+ : `all ${declaredKeyIds.length} registry-declared mail keys have a private half in the vault${provenance}`,
319
+ };
320
+ }
321
+ if (coverage.status === "absent") {
322
+ return {
323
+ id,
324
+ label,
325
+ status: "fail",
326
+ detail: `the mailroom registry declares mail key${absentKeyIds.length === 1 ? "" : "s"} with no private half in this agent's vault — mail encrypted to ${absentKeyIds.join(", ")} cannot be decrypted and cannot be recovered${provenance}; run \`ouro connect mail --agent ${agentDir.replace(/\.ouro$/, "")}\` and, if Mail Control no longer holds the one-time key, rerun it with --rotate-missing-mail-keys to mint a replacement for future mail`,
327
+ };
328
+ }
329
+ return unverified(`mail sense could not verify key coverage${provenance}: ${textField(coverage, "reason") || "no reason recorded"}`);
330
+ }
274
331
  async function checkSenses(deps) {
275
332
  const checks = [];
276
333
  const agents = discoverAgents(deps);
@@ -422,6 +479,10 @@ async function checkSenses(deps) {
422
479
  mailAutonomyDetail(mailroom),
423
480
  ].join("; "),
424
481
  });
482
+ // Config being well-formed says nothing about whether the vault can
483
+ // still decrypt what the registry points mail at — the 2026-07-24 gap
484
+ // sat behind a passing mail config check for 23 hours.
485
+ checks.push(mailKeyCoverageCheck(deps, agentDir));
425
486
  }
426
487
  }
427
488
  }
@@ -442,19 +503,34 @@ function checkHabits(deps) {
442
503
  }
443
504
  checks.push({ label: `${agentDir} habits dir`, status: "pass", detail: habitsDir });
444
505
  const activeScheduledHabits = [];
506
+ const habitLifecycleRows = [];
445
507
  let unreadableHabits = 0;
446
- for (const file of deps.readdirSync(habitsDir).filter((entry) => entry.endsWith(".md"))) {
508
+ for (const file of deps.readdirSync(habitsDir).filter((entry) => entry.endsWith(".md") && entry !== "README.md").sort()) {
447
509
  const filePath = `${habitsDir}/${file}`;
510
+ let habit;
448
511
  try {
449
- const habit = (0, habit_parser_1.parseHabitFile)(deps.readFileSync(filePath), filePath);
450
- if (habit.status === "active" && habit.cadence && (0, cadence_1.parseCadenceToCron)(habit.cadence) !== null) {
451
- activeScheduledHabits.push(habit.name);
452
- }
512
+ habit = (0, habit_parser_1.parseHabitFile)(deps.readFileSync(filePath), filePath);
453
513
  }
454
- catch {
514
+ catch (error) {
455
515
  unreadableHabits += 1;
516
+ habit = (0, habit_parser_1.createDegradedHabitFile)(filePath, "read_error", "", error instanceof Error ? error.message : String(error));
517
+ }
518
+ habitLifecycleRows.push(habit);
519
+ if (habit.status === "active" && habit.cadence && (0, cadence_1.parseCadenceToCron)(habit.cadence) !== null) {
520
+ activeScheduledHabits.push(habit.name);
456
521
  }
457
522
  }
523
+ if (habitLifecycleRows.length > 0) {
524
+ const hasDegradedHabit = habitLifecycleRows.some((habit) => habit.status === "degraded");
525
+ checks.push({
526
+ id: "habits.lifecycle",
527
+ label: `${agentDir} habit lifecycle`,
528
+ status: hasDegradedHabit ? "fail" : "pass",
529
+ detail: habitLifecycleRows.map((habit) => habit.status === "degraded"
530
+ ? `${habit.name}=degraded(${habit.degradedReason})`
531
+ : `${habit.name}=${habit.status}`).join(", "),
532
+ });
533
+ }
458
534
  if (unreadableHabits > 0) {
459
535
  checks.push({
460
536
  label: `${agentDir} habit files`,
@@ -32,7 +32,9 @@ function buildHabitPrivateWakeCommand(options) {
32
32
  { kind: "habit-trigger", id: options.trigger },
33
33
  { kind: "habit-occurrence", id: occurrenceId },
34
34
  options.sourceRef,
35
+ ...(options.noSend ? [{ kind: "capability", id: "no-send" }] : []),
35
36
  ],
37
+ ...(options.noSend ? { noSend: true } : {}),
36
38
  };
37
39
  }
38
40
  function habitMessageFromPrivateWakeCommand(command) {
@@ -42,8 +44,11 @@ function habitMessageFromPrivateWakeCommand(command) {
42
44
  return null;
43
45
  const habitRef = command.originRefs?.find((ref) => ref.kind === "habit" && typeof ref.id === "string");
44
46
  const triggerRef = command.originRefs?.find((ref) => ref.kind === "habit-trigger" && typeof ref.id === "string");
47
+ const noSendRef = command.originRefs?.some((ref) => ref.kind === "capability" && ref.id === "no-send") ?? false;
45
48
  if (!habitRef || typeof habitRef.id !== "string" || !triggerRef || typeof triggerRef.id !== "string")
46
49
  return null;
50
+ if ((command.noSend === true) !== noSendRef)
51
+ return null;
47
52
  const habitName = habitRef.id.trim();
48
53
  const trigger = triggerRef.id.trim();
49
54
  if (habitName.length === 0)
@@ -52,5 +57,5 @@ function habitMessageFromPrivateWakeCommand(command) {
52
57
  return null;
53
58
  if (command.triggerSource !== `habit-${trigger}`)
54
59
  return null;
55
- return { habitName, trigger };
60
+ return { habitName, trigger, ...(noSendRef ? { noSend: true } : {}) };
56
61
  }