session-orchestrator 3.20.0 → 3.22.0

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 (202) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.codex-plugin/plugin.json +1 -1
  4. package/.cursor/rules/000-session-orchestrator.mdc +3 -2
  5. package/.cursor/rules/030-wave-execution.mdc +10 -8
  6. package/.cursor/rules/040-discovery.mdc +6 -6
  7. package/.cursor/rules/050-plan.mdc +8 -8
  8. package/CHANGELOG.md +515 -0
  9. package/README.md +16 -11
  10. package/agents/analyst.md +1 -1
  11. package/agents/architect-reviewer.md +1 -1
  12. package/agents/code-implementer.md +4 -2
  13. package/agents/db-specialist.md +1 -1
  14. package/agents/dialectic-deriver.md +1 -1
  15. package/agents/docs-writer.md +1 -1
  16. package/agents/memory-proposal-collector.md +7 -5
  17. package/agents/qa-strategist.md +1 -1
  18. package/agents/security-reviewer.md +1 -1
  19. package/agents/session-reviewer.md +42 -1
  20. package/agents/skill-applied-judge.md +1 -1
  21. package/agents/test-writer.md +1 -1
  22. package/agents/ui-developer.md +1 -1
  23. package/agents/ux-evaluator.md +1 -1
  24. package/commands/eli5.md +33 -0
  25. package/commands/release.md +62 -0
  26. package/commands/test.md +2 -2
  27. package/docs/components.md +6 -5
  28. package/docs/migration-v3.md +9 -6
  29. package/docs/persona-panel.md +3 -1
  30. package/docs/scope-collision-guard.md +167 -0
  31. package/docs/session-config-reference.md +31 -8
  32. package/hooks/_lib/lock-bootstrap.mjs +19 -13
  33. package/hooks/enforce-scope.mjs +103 -3
  34. package/hooks/hooks-codex.json +1 -1
  35. package/hooks/hooks.json +21 -1
  36. package/hooks/on-session-end.mjs +76 -97
  37. package/hooks/on-session-start.mjs +195 -104
  38. package/hooks/on-stop.mjs +127 -12
  39. package/hooks/post-bash-write-verify.mjs +8 -32
  40. package/hooks/pre-auq-clarity.mjs +787 -0
  41. package/hooks/pre-bash-issue-budget.mjs +17 -18
  42. package/hooks/pre-task-scope-disjoint.mjs +1042 -0
  43. package/package.json +3 -1
  44. package/pi/prompts/eli5.md +12 -0
  45. package/pi/prompts/release.md +12 -0
  46. package/scripts/auq-audit.mjs +825 -0
  47. package/scripts/autopilot.mjs +10 -9
  48. package/scripts/emit-session.mjs +42 -0
  49. package/scripts/export-hw-learnings.mjs +61 -2
  50. package/scripts/lib/auq/clarity.mjs +1314 -0
  51. package/scripts/lib/auq/parse.mjs +1006 -0
  52. package/scripts/lib/auq/schema.mjs +1457 -0
  53. package/scripts/lib/autopilot/worktree-pipeline.mjs +5 -5
  54. package/scripts/lib/backlog-scan.mjs +106 -15
  55. package/scripts/lib/build-live-signals.mjs +7 -3
  56. package/scripts/lib/ci-status-banner.mjs +267 -77
  57. package/scripts/lib/config/dispatcher-autonomy-capture.mjs +32 -9
  58. package/scripts/lib/config/vault-integration.mjs +12 -1
  59. package/scripts/lib/dispatcher/rank.mjs +4 -7
  60. package/scripts/lib/gates/gate-full.mjs +3 -3
  61. package/scripts/lib/gates/gate-helpers.mjs +17 -6
  62. package/scripts/lib/git-config-drift.mjs +471 -0
  63. package/scripts/lib/harness-audit/categories/category6.mjs +65 -12
  64. package/scripts/lib/io.mjs +432 -7
  65. package/scripts/lib/issue-budget.mjs +63 -9
  66. package/scripts/lib/learnings/select.mjs +157 -3
  67. package/scripts/lib/memory-cleanup-stamp.mjs +132 -8
  68. package/scripts/lib/mirror-issues-banner.mjs +266 -0
  69. package/scripts/lib/named-vault-resolver.mjs +105 -16
  70. package/scripts/lib/owner-interview.mjs +78 -32
  71. package/scripts/lib/peer-cards/schema.mjs +6 -2
  72. package/scripts/lib/peer-discovery.mjs +73 -22
  73. package/scripts/lib/project-hygiene.mjs +64 -4
  74. package/scripts/lib/reconcile/renderer.mjs +17 -4
  75. package/scripts/lib/reconcile/writer.mjs +69 -30
  76. package/scripts/lib/redact-spans.mjs +89 -0
  77. package/scripts/lib/resource-probe/evaluate.mjs +330 -149
  78. package/scripts/lib/resource-probe/probe-platform.mjs +35 -0
  79. package/scripts/lib/resource-probe.mjs +18 -2
  80. package/scripts/lib/scope-baseline.mjs +77 -17
  81. package/scripts/lib/scope-gate.mjs +658 -0
  82. package/scripts/lib/secret-masker.mjs +262 -0
  83. package/scripts/lib/session-lock.mjs +34 -10
  84. package/scripts/lib/session-registry.mjs +9 -1
  85. package/scripts/lib/spiral-carryover.mjs +23 -2
  86. package/scripts/lib/state-md/mission-status.mjs +164 -58
  87. package/scripts/lib/tmux-layout/vcs-detector.mjs +108 -4
  88. package/scripts/lib/validate/check-agents.mjs +77 -5
  89. package/scripts/lib/validate/check-auq-clarity.mjs +274 -0
  90. package/scripts/lib/validate/check-commands.mjs +2 -20
  91. package/scripts/lib/validate/check-doc-cli-commands.mjs +514 -0
  92. package/scripts/lib/validate/check-hooks-symmetry.mjs +48 -0
  93. package/scripts/lib/validate/check-owner-leakage.mjs +185 -17
  94. package/scripts/lib/validate/check-rules.mjs +153 -9
  95. package/scripts/lib/validate/check-skills.mjs +191 -0
  96. package/scripts/lib/validate/check-test-git-config-target.mjs +665 -0
  97. package/scripts/lib/validate/check-unicode-safety.mjs +22 -2
  98. package/scripts/lib/validate/check-untracked-test-deps.mjs +925 -0
  99. package/scripts/lib/validate/check-unwired-features.mjs +219 -11
  100. package/scripts/lib/validate/check-vcs-repo-flag.mjs +965 -0
  101. package/scripts/lib/validate/frontmatter-block.mjs +61 -0
  102. package/scripts/lib/validate/tier-inference.mjs +46 -8
  103. package/scripts/lib/vault-backfill/glab.mjs +91 -58
  104. package/scripts/lib/vault-backfill/manifest.mjs +28 -8
  105. package/scripts/lib/vault-mirror/namespace.mjs +146 -1
  106. package/scripts/lib/vault-mirror/process.mjs +264 -31
  107. package/scripts/lib/vault-mirror/render-sessions.mjs +115 -4
  108. package/scripts/lib/vault-status/board-writer.mjs +300 -56
  109. package/scripts/lib/vault-status/narrative-mirror.mjs +119 -5
  110. package/scripts/lib/vcs-repo-spec.mjs +680 -30
  111. package/scripts/lib/wave-resource-gate.mjs +67 -73
  112. package/scripts/materialize-wave-scope.mjs +281 -0
  113. package/scripts/print-learnings-index.mjs +30 -3
  114. package/scripts/release.mjs +983 -107
  115. package/scripts/run-quality-gate.mjs +14 -0
  116. package/scripts/site-numbers.mjs +1049 -0
  117. package/scripts/validate-plugin.mjs +64 -0
  118. package/scripts/validate-wave-scope.mjs +286 -12
  119. package/scripts/vault-backfill.mjs +32 -5
  120. package/scripts/vault-mirror.mjs +26 -1
  121. package/skills/_shared/monitor-patterns.md +24 -4
  122. package/skills/_shared/parallel-aware-auq.md +30 -24
  123. package/skills/_shared/parallel-aware-preamble.md +31 -2
  124. package/skills/_shared/state-ownership.md +49 -6
  125. package/skills/bootstrap/SKILL.md +2 -1
  126. package/skills/brainstorm/SKILL.md +18 -18
  127. package/skills/brainstorm/soul.md +12 -0
  128. package/skills/claude-md-drift-check/SKILL.md +9 -1
  129. package/skills/debug/SKILL.md +4 -1
  130. package/skills/discovery/SKILL.md +28 -24
  131. package/skills/discovery/issue-templates.md +4 -4
  132. package/skills/discovery/probes-code.md +2 -2
  133. package/skills/discovery/probes-feature.md +6 -6
  134. package/skills/discovery/probes-infra.md +2 -2
  135. package/skills/discovery/probes-session.md +5 -5
  136. package/skills/dispatcher/SKILL.md +10 -1
  137. package/skills/eli5/SKILL.md +43 -0
  138. package/skills/evolve/SKILL.md +8 -9
  139. package/skills/frontmatter-guard/SKILL.md +9 -1
  140. package/skills/gitlab-ops/SKILL.md +73 -59
  141. package/skills/gitlab-portfolio/SKILL.md +10 -1
  142. package/skills/grill/SKILL.md +6 -6
  143. package/skills/grill/soul.md +16 -0
  144. package/skills/memory-cleanup/SKILL.md +20 -7
  145. package/skills/npm-publish/SKILL.md +23 -51
  146. package/skills/peekaboo-driver/SKILL.md +3 -3
  147. package/skills/persona-panel/SKILL.md +3 -1
  148. package/skills/plan/SKILL.md +18 -16
  149. package/skills/plan/mode-feature.md +1 -1
  150. package/skills/plan/mode-new.md +42 -12
  151. package/skills/plan/soul.md +12 -0
  152. package/skills/reconcile/SKILL.md +3 -3
  153. package/skills/repo-audit/SKILL.md +10 -1
  154. package/skills/session-end/SKILL.md +97 -22
  155. package/skills/session-end/metrics-collection.md +1 -1
  156. package/skills/session-end/phase-3-6-tail.md +37 -2
  157. package/skills/session-end/session-metrics-write.md +4 -10
  158. package/skills/session-plan/SKILL.md +2 -2
  159. package/skills/session-plan/wave-template.md +1 -1
  160. package/skills/session-start/SKILL.md +82 -36
  161. package/skills/session-start/phase-2-5-docs-planning.md +8 -8
  162. package/skills/session-start/phase-4-5-resource-health.md +82 -19
  163. package/skills/session-start/soul.md +110 -0
  164. package/skills/spinout/SKILL.md +5 -1
  165. package/skills/sunset-review/SKILL.md +11 -1
  166. package/skills/test-runner/SKILL.md +2 -2
  167. package/skills/tmux-layout/SKILL.md +7 -2
  168. package/skills/using-orchestrator/SKILL.md +1 -1
  169. package/skills/vault-mirror/SKILL.md +10 -1
  170. package/skills/vault-sync/SKILL.md +10 -1
  171. package/skills/vault-sync/validator.mjs +55 -6
  172. package/skills/wave-executor/wave-loop.md +64 -12
  173. package/skills/write-executable-plan/SKILL.md +6 -6
  174. package/scripts/lib/mission-status-schema.mjs +0 -114
  175. package/scripts/tests/fixtures/fetch-baseline/sample-rule.md +0 -8
  176. package/skills/vault-sync/tests/fixtures/archive-test-vault/90-archive/bad-archived.md +0 -8
  177. package/skills/vault-sync/tests/fixtures/archive-test-vault/_meta/.gitkeep +0 -0
  178. package/skills/vault-sync/tests/fixtures/archive-test-vault/live-note.md +0 -8
  179. package/skills/vault-sync/tests/fixtures/broken-frontmatter-vault/_meta/.gitkeep +0 -0
  180. package/skills/vault-sync/tests/fixtures/broken-frontmatter-vault/bad-type.md +0 -8
  181. package/skills/vault-sync/tests/fixtures/broken-frontmatter-vault/good-note.md +0 -8
  182. package/skills/vault-sync/tests/fixtures/clean-vault/.obsidian/config.md +0 -8
  183. package/skills/vault-sync/tests/fixtures/clean-vault/01-projects/foo/projects-baseline.md +0 -10
  184. package/skills/vault-sync/tests/fixtures/clean-vault/03-daily/daily-2026-04-13.md +0 -8
  185. package/skills/vault-sync/tests/fixtures/clean-vault/README.md +0 -3
  186. package/skills/vault-sync/tests/fixtures/clean-vault/hello-world.md +0 -11
  187. package/skills/vault-sync/tests/fixtures/dangling-link-vault/_meta/.gitkeep +0 -0
  188. package/skills/vault-sync/tests/fixtures/dangling-link-vault/has-dangling.md +0 -9
  189. package/skills/vault-sync/tests/fixtures/dangling-link-vault/real-target.md +0 -8
  190. package/skills/vault-sync/tests/fixtures/empty-vault/_meta/.gitkeep +0 -0
  191. package/skills/vault-sync/tests/fixtures/missing-field-vault/_meta/.gitkeep +0 -0
  192. package/skills/vault-sync/tests/fixtures/missing-field-vault/missing-id.md +0 -7
  193. package/skills/vault-sync/tests/fixtures/nested-tag-vault/03-daily/daily-2026-04-13.md +0 -9
  194. package/skills/vault-sync/tests/fixtures/nested-tag-vault/_meta/.gitkeep +0 -0
  195. package/skills/vault-sync/tests/fixtures/nested-tag-vault/nested-tags-note.md +0 -11
  196. package/skills/vault-sync/tests/fixtures/no-frontmatter-vault/README.md +0 -3
  197. package/skills/vault-sync/tests/fixtures/no-frontmatter-vault/_MOC.md +0 -3
  198. package/skills/vault-sync/tests/fixtures/no-frontmatter-vault/_meta/.gitkeep +0 -0
  199. package/skills/vault-sync/tests/fixtures/with-moc-vault/_MOC.md +0 -11
  200. package/skills/vault-sync/tests/fixtures/with-moc-vault/_meta/.gitkeep +0 -0
  201. package/skills/vault-sync/tests/fixtures/with-moc-vault/hello-world.md +0 -11
  202. package/skills/vault-sync/tests/schema-drift.test.mjs +0 -133
@@ -8,12 +8,70 @@
8
8
  * Extended in v3.2 Phase C-2 (#296): swap + memory_pressure verdict rules.
9
9
  * Zombie rule wired end-to-end in #178.
10
10
  * Split from resource-probe.mjs in #287 (hotspot 2/2).
11
+ * Rebuilt in #1089 — signal precedence + right units + the two-signal rule.
12
+ *
13
+ * ---------------------------------------------------------------------------
14
+ * #1089 — why this file was rebuilt
15
+ * ---------------------------------------------------------------------------
16
+ * Measured 2026-08-21 over 1477 `orchestrator.session.started` events across 18
17
+ * repos (2026-04-19 → 2026-08-21): the previous rule set produced a warn-or-worse
18
+ * verdict on 1462 of them — 99.0%. The three drivers were each a MEASUREMENT
19
+ * defect, not a threshold set too tight:
20
+ *
21
+ * 1. `ram_free_gb` is `os.freemem()`, which on Darwin reports only `Pages free`.
22
+ * Median across the corpus: 0.4 GB; 53.8% of samples below 0.5 GB. So
23
+ * `ram-free-critical-gb: 2` fired on 84.0% of ALL session starts. Six repos
24
+ * independently recorded this as a false alarm in their learnings store —
25
+ * one at confidence 1.0 ("decide concurrency on ramAvailableGb, not the free
26
+ * number"), one noting "five consecutive sessions wrongly capped at 2".
27
+ * 2. `concurrent-sessions-warn` is named for SESSIONS but was compared against
28
+ * `claude_processes_count`. Measured ratio processes:sessions = 6.0. That
29
+ * threshold fired on 93.6% of starts; against the real peer count it fires
30
+ * on 4.2%.
31
+ * 3. Any ONE soft signal capped agents-per-wave at 2 outright, so the union of
32
+ * three noisy axes decided wave size on its noisiest member.
33
+ *
34
+ * Counter-evidence that makes this a re-aim rather than a removal: the
35
+ * 2026-04-19 incident (8-session host freeze + 2 worktree OOMs) was real, and a
36
+ * separate repo recorded (conf 0.9) that Chromium mis-paints under genuine
37
+ * memory pressure. The hazard exists — the old detector could not see it through
38
+ * its own noise floor.
39
+ *
40
+ * The rebuilt rules keep every protection and change what is measured:
41
+ *
42
+ * - SIGNAL PRECEDENCE (memory): judge on the best available signal, never on a
43
+ * worse one that merely happens to also be present. `memory_pressure_pct_free`
44
+ * (the OS's own health verdict) outranks `ram_available_gb`, which outranks
45
+ * `ram_free_gb`. On Linux/Windows `os.freemem()` IS accurate and stays the
46
+ * signal — the demotion is Darwin-specific by construction, since only Darwin
47
+ * publishes the two better signals. The old code merely SUPPRESSED a free-RAM
48
+ * verdict when pressure was healthy, which left the whole band
49
+ * "pressure present but <30%" still being judged on Pages-free.
50
+ * - RIGHT UNIT (concurrency): `peer_sessions_count` (live session registry)
51
+ * when present. `claude_processes_count` survives only as a fallback, scaled
52
+ * by the measured PROCESSES_PER_SESSION factor so the configured threshold
53
+ * keeps meaning "sessions" on hosts where the registry is unavailable.
54
+ * - TWO-SIGNAL RULE: a single soft signal is informational — reported in
55
+ * `reasons`, capping nothing. A cap needs two INDEPENDENT soft signals
56
+ * agreeing, or one hard signal. Simulated on the same 1477-sample corpus:
57
+ * `cpu>90 AND peers>=3` fires on 2.2%, `peers>=4` alone on 7.4%.
58
+ *
59
+ * Governing principle, stated here and in `.claude/rules/host-resources.md`:
60
+ * a signal may only warn if it is rare. A warning class firing on more than ~10%
61
+ * of starts is a broken instrument to be re-aimed, not a policy to obey.
11
62
  */
12
63
 
13
64
  // ---------------------------------------------------------------------------
14
65
  // Verdict precedence helper
15
66
  // ---------------------------------------------------------------------------
16
67
 
68
+ /**
69
+ * `degraded` is retained in the rank table for back-compat: `evaluate()` no
70
+ * longer PRODUCES it (the rebuilt rules emit green | warn | critical only), but
71
+ * external callers may still pass a stored legacy verdict through
72
+ * {@link bumpVerdict}, and ranking it between warn and critical keeps that
73
+ * comparison meaningful.
74
+ */
17
75
  const VERDICT_RANK = { green: 0, warn: 1, degraded: 2, critical: 3 };
18
76
 
19
77
  /**
@@ -22,47 +80,176 @@ const VERDICT_RANK = { green: 0, warn: 1, degraded: 2, critical: 3 };
22
80
  * @param {string} target
23
81
  * @returns {string}
24
82
  */
25
- function bumpVerdict(current, target) {
83
+ export function bumpVerdict(current, target) {
26
84
  const currentRank = VERDICT_RANK[current] ?? 0;
27
85
  const targetRank = VERDICT_RANK[target] ?? 0;
28
86
  return targetRank > currentRank ? target : current;
29
87
  }
30
88
 
31
89
  /**
32
- * macOS healthy-pressure threshold. When `memory_pressure_pct_free` this value,
33
- * the OS reports that compressor + caches are doing their job, and `ram_free_gb`
34
- * (which on macOS reflects only `Pages free`, not `inactive`) is misleading
35
- * — the system has plenty of reclaimable memory. We suppress free-RAM-only
36
- * verdict-bumps in that regime.
90
+ * macOS memory_pressure bands, as percentages of memory the OS reports free.
37
91
  *
38
- * Source: Apple Activity Monitor uses memory_pressure as the canonical health
39
- * indicator since 10.9 Mavericks. See OSXDaily (2026-04) + Apple developer docs
40
- * "Viewing Virtual Memory Usage". Threshold 30% matches the green/yellow
41
- * boundary rendered by Activity Monitor (yellow at 30..50%, red at <30%).
92
+ * Apple's Activity Monitor has used memory_pressure — not "free RAM" — as the
93
+ * canonical health indicator since 10.9 Mavericks; these bands match the colours
94
+ * it renders (green ≥30, yellow 15..30, red <15).
95
+ *
96
+ * HEALTHY (≥30): compressor + caches are keeping up. No memory signal at all.
97
+ * SOFT (<30): worth reporting; not worth capping on its own.
98
+ * HARD (<15): genuine pressure — the band the 2026-04-19 freeze sat in.
42
99
  */
43
- const MACOS_HEALTHY_PRESSURE_PCT = 30;
100
+ export const PRESSURE_HEALTHY_PCT = 30;
101
+ export const PRESSURE_SOFT_PCT = 30;
102
+ export const PRESSURE_HARD_PCT = 15;
44
103
 
45
104
  /**
46
- * Evaluate a snapshot against `resource-thresholds` (from Session Config #166)
47
- * and return a verdict used by session-start Phase 4.5.
48
- * @param {object} snapshot output of probe()
49
- * @param {object} thresholds resource-thresholds block from parseSessionConfig
50
- * @param {{heavyRepo?: boolean, agentsPerWave?: number|{default: number, [mode: string]: number}}} [options] — HR-003/HR-004
51
- * preflight ceiling (baseline #60). When `heavyRepo` is true and `agentsPerWave`
52
- * resolves to a number (see {@link resolveAgentsPerWaveCap}), `recommended_agents_per_wave_cap`
53
- * is forced to at most that number REGARDLESS of the live-probe verdict — a
54
- * static preflight ceiling, not a runtime signal. More-restrictive-wins:
55
- * effective = min(existing cap ?? Infinity, agentsPerWave). Omitted entirely
56
- * = today's behaviour (back-compat).
105
+ * Measured processes-per-session factor (median `claude_processes_count` /
106
+ * `peer_count` = 6.0 over 1461 paired samples, 2026-08-21). Used ONLY to rescale
107
+ * the session-denominated `concurrent-sessions-warn` threshold when the live
108
+ * registry count is unavailable and a raw process count is all we have.
109
+ *
110
+ * Named ceiling (BV-004): this is one host family's ratio, not a law. Revisit if
111
+ * `peer_sessions_count` is unavailable on a host where Claude Code's per-session
112
+ * process fan-out differs materially the fallback then mis-scales in whichever
113
+ * direction the real ratio moved.
114
+ */
115
+ export const PROCESSES_PER_SESSION = 6;
116
+
117
+ /** Swap bands (MB). Only consulted when the memory signal is NOT healthy. */
118
+ const SWAP_HARD_MB = 3072;
119
+ const SWAP_SOFT_MB = 1024;
120
+
121
+ /**
122
+ * Canonical `resource-thresholds` defaults — the ONE definition (#1089).
123
+ *
124
+ * Before this constant existed there were three, and they had drifted:
125
+ * `config/vault-integration.mjs` had 4 / 2 / 80 / 5, `dispatcher/rank.mjs` had
126
+ * the same four, and `autopilot.mjs` had 2.0 / 0.5 / 85 / 3 under a comment
127
+ * claiming it "mirrors resource-probe hard-coded values" — so autopilot was
128
+ * judging every host against a threshold set nothing else used, and the comment
129
+ * asserting otherwise is what kept anyone from noticing. Config-supplied values
130
+ * still override these; this is only the floor when a repo declares none.
131
+ */
132
+ export const DEFAULT_RESOURCE_THRESHOLDS = Object.freeze({
133
+ 'ram-free-min-gb': 4,
134
+ 'ram-free-critical-gb': 2,
135
+ 'cpu-load-max-pct': 90,
136
+ 'concurrent-sessions-warn': 5,
137
+ });
138
+
139
+ /**
140
+ * Resolve which memory signal to judge on, in strict precedence order.
141
+ *
142
+ * This is the heart of the #1089 fix. The old code fell back to `ram_free_gb`
143
+ * whenever `ram_available_gb` was absent, EVEN IF `memory_pressure_pct_free` was
144
+ * present but merely below its healthy band — so a Darwin host reporting
145
+ * "0.3 GB free / 20% pressure-free" was judged on the 0.3 and went critical.
146
+ * Precedence removes that: a better signal, when present, does not suppress the
147
+ * worse one, it REPLACES it.
148
+ *
149
+ * @param {object} snapshot
150
+ * @param {number} ramMin — `ram-free-min-gb`
151
+ * @param {number} ramCrit — `ram-free-critical-gb`
152
+ * @returns {{kind: 'pressure'|'available'|'free', hard: boolean, soft: boolean, reason: string|null}}
153
+ */
154
+ function memorySignal(snapshot, ramMin, ramCrit) {
155
+ const { ram_free_gb, ram_available_gb, memory_pressure_pct_free } = snapshot;
156
+
157
+ const hasPressure =
158
+ memory_pressure_pct_free !== null && memory_pressure_pct_free !== undefined;
159
+ if (hasPressure) {
160
+ if (memory_pressure_pct_free < PRESSURE_HARD_PCT) {
161
+ return {
162
+ kind: 'pressure',
163
+ hard: true,
164
+ soft: false,
165
+ reason: `macOS memory_pressure ${memory_pressure_pct_free}% free — below the hard band (<${PRESSURE_HARD_PCT}%); the OS reports genuine memory pressure.`,
166
+ };
167
+ }
168
+ if (memory_pressure_pct_free < PRESSURE_SOFT_PCT) {
169
+ return {
170
+ kind: 'pressure',
171
+ hard: false,
172
+ soft: true,
173
+ reason: `macOS memory_pressure ${memory_pressure_pct_free}% free — in the soft band (${PRESSURE_HARD_PCT}..${PRESSURE_SOFT_PCT}%).`,
174
+ };
175
+ }
176
+ return {
177
+ kind: 'pressure',
178
+ hard: false,
179
+ soft: false,
180
+ reason: `macOS memory_pressure healthy (${memory_pressure_pct_free}% free ≥ ${PRESSURE_HEALTHY_PCT}%) — free-RAM not consulted (Pages-free underreports on Darwin).`,
181
+ };
182
+ }
183
+
184
+ const hasAvailable =
185
+ ram_available_gb !== null && ram_available_gb !== undefined;
186
+ const effective = hasAvailable ? ram_available_gb : ram_free_gb;
187
+ const label = hasAvailable ? 'RAM available' : 'RAM free';
188
+
189
+ if (effective < ramCrit) {
190
+ return {
191
+ kind: hasAvailable ? 'available' : 'free',
192
+ hard: true,
193
+ soft: false,
194
+ reason: `${label} ${effective.toFixed(1)} GB below critical threshold ${ramCrit} GB.`,
195
+ };
196
+ }
197
+ if (effective < ramMin) {
198
+ return {
199
+ kind: hasAvailable ? 'available' : 'free',
200
+ hard: false,
201
+ soft: true,
202
+ reason: `${label} ${effective.toFixed(1)} GB below threshold ${ramMin} GB.`,
203
+ };
204
+ }
205
+ return {
206
+ kind: hasAvailable ? 'available' : 'free',
207
+ hard: false,
208
+ soft: false,
209
+ reason: null,
210
+ };
211
+ }
212
+
213
+ /**
214
+ * Resolve the concurrency signal, preferring the live session registry count
215
+ * over the raw process count. Returns `null` when neither is available.
57
216
  *
58
- * `agentsPerWave` accepts either a plain number OR the `{default, <mode>: N}`
59
- * object `_coerceInteger()` (scripts/lib/config/coercers.mjs) produces for
60
- * the parenthetical override syntax (e.g. `agents-per-wave: 4 (deep: 18)`) —
61
- * `skills/session-start/phase-4-5-resource-health.md` documents wiring
62
- * `config['agents-per-wave']` straight into this option, so the object shape
63
- * is a real input here, not a hypothetical one.
64
- * @returns {{verdict: 'green'|'warn'|'degraded'|'critical', reasons: string[], recommended_agents_per_wave_cap: number|null}}
217
+ * @param {object} snapshot
218
+ * @param {number} concWarn — configured threshold, denominated in SESSIONS
219
+ * @returns {{soft: boolean, reason: string|null}|null}
65
220
  */
221
+ function concurrencySignal(snapshot, concWarn) {
222
+ const { peer_sessions_count, claude_processes_count } = snapshot;
223
+
224
+ const hasPeers =
225
+ peer_sessions_count !== null && peer_sessions_count !== undefined;
226
+ if (hasPeers) {
227
+ if (peer_sessions_count >= concWarn) {
228
+ return {
229
+ soft: true,
230
+ reason: `${peer_sessions_count} peer session(s) live in the registry (threshold: ${concWarn}) — consider sequencing this session after others finish.`,
231
+ };
232
+ }
233
+ return { soft: false, reason: null };
234
+ }
235
+
236
+ const hasProcs =
237
+ claude_processes_count !== null && claude_processes_count !== undefined;
238
+ if (!hasProcs) return null;
239
+
240
+ // Registry unavailable — fall back to the process count, rescaled so the
241
+ // session-denominated threshold keeps its meaning. Without this factor the
242
+ // comparison carries a measured 6x unit error, which is what fired on 93.6%
243
+ // of session starts before #1089.
244
+ const procThreshold = concWarn * PROCESSES_PER_SESSION;
245
+ if (claude_processes_count >= procThreshold) {
246
+ return {
247
+ soft: true,
248
+ reason: `${claude_processes_count} Claude processes running (fallback threshold ${procThreshold} = ${concWarn} sessions × ${PROCESSES_PER_SESSION} processes/session; the live session registry was unavailable).`,
249
+ };
250
+ }
251
+ return { soft: false, reason: null };
252
+ }
66
253
 
67
254
  /**
68
255
  * Resolve an `agentsPerWave` option value into a plain numeric cap, or `null`
@@ -89,12 +276,32 @@ function resolveAgentsPerWaveCap(cap) {
89
276
  return null;
90
277
  }
91
278
 
279
+ /**
280
+ * Evaluate a snapshot against `resource-thresholds` (from Session Config #166)
281
+ * and return a verdict used by session-start Phase 4.5.
282
+ *
283
+ * @param {object} snapshot — output of probe()
284
+ * @param {object} thresholds — resource-thresholds block from parseSessionConfig
285
+ * @param {{heavyRepo?: boolean, agentsPerWave?: number|{default: number, [mode: string]: number}}} [options] — HR-003/HR-004
286
+ * preflight ceiling (baseline #60). When `heavyRepo` is true and `agentsPerWave`
287
+ * resolves to a number, `recommended_agents_per_wave_cap` is forced to at most
288
+ * that number REGARDLESS of the live-probe verdict — a static preflight ceiling,
289
+ * not a runtime signal. More-restrictive-wins. Omitted entirely = back-compat.
290
+ * @returns {{verdict: 'green'|'warn'|'critical', reasons: string[], recommended_agents_per_wave_cap: number|null, signals: {hard: string[], soft: string[]}}}
291
+ */
92
292
  export function evaluate(snapshot, thresholds, options = {}) {
93
293
  const reasons = [];
94
- let verdict = 'green';
95
- let cap = null;
294
+ const hardSignals = [];
295
+ const softSignals = [];
96
296
 
97
- const { ram_free_gb, cpu_load_pct, cpu_load_5m_pct, claude_processes_count, memory_pressure_pct_free, ram_available_gb } = snapshot;
297
+ const {
298
+ cpu_load_pct,
299
+ cpu_load_5m_pct,
300
+ claude_processes_count,
301
+ peer_sessions_count,
302
+ swap_used_mb,
303
+ zombie_processes_count,
304
+ } = snapshot;
98
305
  const {
99
306
  'ram-free-min-gb': ramMin,
100
307
  'ram-free-critical-gb': ramCrit,
@@ -102,142 +309,111 @@ export function evaluate(snapshot, thresholds, options = {}) {
102
309
  'concurrent-sessions-warn': concWarn,
103
310
  } = thresholds;
104
311
 
105
- // On macOS, `os.freemem()` reports only `Pages free` (excludes `inactive`),
106
- // so `ram_free_gb` regularly reads sub-1 GB even when the system has 80+ GB
107
- // reclaimable. The robust fix (#667): when `ram_available_gb` (free +
108
- // inactive + speculative + purgeable, derived from vm_stat) is present, judge
109
- // RAM-criticality on AVAILABLE rather than FREE. The `memory_pressure`
110
- // healthy-suppression below is retained as a secondary guard for the case
111
- // where vm_stat is unavailable but memory_pressure still reports.
112
- const hasAvailable =
113
- ram_available_gb !== null && ram_available_gb !== undefined;
114
- const effectiveRamGb = hasAvailable ? ram_available_gb : ram_free_gb;
115
- const ramLabel = hasAvailable ? 'RAM available' : 'RAM free';
116
-
117
- const macosPressureHealthy =
118
- memory_pressure_pct_free !== null &&
119
- memory_pressure_pct_free !== undefined &&
120
- memory_pressure_pct_free >= MACOS_HEALTHY_PRESSURE_PCT;
121
-
122
- if (!macosPressureHealthy) {
123
- if (effectiveRamGb < ramCrit) {
124
- verdict = 'critical';
125
- cap = 0;
126
- reasons.push(`${ramLabel} ${effectiveRamGb.toFixed(1)} GB below critical threshold ${ramCrit} GB — recommend coordinator-direct (0 agents).`);
127
- } else if (effectiveRamGb < ramMin) {
128
- if (verdict === 'green') verdict = 'warn';
129
- cap = cap === null ? 2 : Math.min(cap, 2);
130
- reasons.push(`${ramLabel} ${effectiveRamGb.toFixed(1)} GB below threshold ${ramMin} GB — capping agents-per-wave at 2.`);
131
- }
132
- } else {
133
- reasons.push(`macOS memory_pressure healthy (${memory_pressure_pct_free}% free ≥ ${MACOS_HEALTHY_PRESSURE_PCT}%) — free-RAM signal suppressed (Pages-free underreports on Darwin).`);
312
+ // --- Memory axis: strict signal precedence (see memorySignal docstring) ----
313
+ const mem = memorySignal(snapshot, ramMin, ramCrit);
314
+ if (mem.hard) {
315
+ hardSignals.push('memory');
316
+ reasons.push(mem.reason);
317
+ } else if (mem.soft) {
318
+ softSignals.push('memory');
319
+ reasons.push(mem.reason);
320
+ } else if (mem.reason) {
321
+ // Healthy-pressure note kept because it explains why an alarming
322
+ // "0.x GB free" banner number did NOT drive the verdict.
323
+ reasons.push(mem.reason);
134
324
  }
325
+ const memoryHealthy = !mem.hard && !mem.soft;
135
326
 
136
- // CPU axis (#943): the 1m load average systematically carries the decaying
137
- // tail of the coordinator's own just-finished gate run (the caller sits right
138
- // after the inter-wave Quality Gate by construction). When the probe supplied
139
- // a numeric `cpu_load_5m_pct`, judge CPU on min(1m, 5m) only-1m-high is a
140
- // decaying transient (informational, no cap), both-high is genuine sustained
141
- // load. Mirrors the memory_pressure healthy-suppression pattern above.
142
- // `cpu_load_5m_pct` absent/null (legacy snapshots, Windows) → legacy 1m-only.
327
+ // --- CPU axis (#943) ------------------------------------------------------
328
+ // The 1m load average systematically carries the decaying tail of the
329
+ // coordinator's own just-finished gate run (the caller sits right after the
330
+ // inter-wave Quality Gate by construction). Judge on min(1m, 5m) when the
331
+ // probe supplied a numeric 5m value: only-1m-high is a decaying transient,
332
+ // both-high is genuine sustained load. Null 5m (Windows, zero-load) 1m only.
143
333
  const has5mCpu = typeof cpu_load_5m_pct === 'number' && Number.isFinite(cpu_load_5m_pct);
144
334
  const effectiveCpuPct = has5mCpu ? Math.min(cpu_load_pct, cpu_load_5m_pct) : cpu_load_pct;
145
335
  if (effectiveCpuPct > cpuMax) {
146
- if (verdict === 'green') verdict = 'warn';
147
- cap = cap === null ? 2 : Math.min(cap, 2);
336
+ softSignals.push('cpu');
148
337
  const detail = has5mCpu ? ` (min of 1m ${cpu_load_pct}% / 5m ${cpu_load_5m_pct}%)` : '';
149
- reasons.push(`CPU load ${effectiveCpuPct}%${detail} above threshold ${cpuMax}% — capping agents-per-wave at 2.`);
338
+ reasons.push(`CPU load ${effectiveCpuPct}%${detail} above threshold ${cpuMax}%.`);
150
339
  } else if (has5mCpu && cpu_load_pct > cpuMax) {
151
- // Informational only: 1m spike with a calm 5m average = decaying transient
152
- // (typically the coordinator's own just-finished gate run). No cap.
153
- reasons.push(`CPU 1m load ${cpu_load_pct}% above threshold ${cpuMax}% but 5m load ${cpu_load_5m_pct}% is below — decaying transient (likely the coordinator's own gate run); no cap (#943).`);
154
- }
155
-
156
- if (claude_processes_count !== null && claude_processes_count !== undefined && claude_processes_count >= concWarn) {
157
- if (verdict === 'green') verdict = 'warn';
158
- reasons.push(`${claude_processes_count} Claude processes already running (threshold: ${concWarn}) — consider sequencing this session after others finish.`);
340
+ reasons.push(`info: CPU 1m load ${cpu_load_pct}% above threshold ${cpuMax}% but 5m load ${cpu_load_5m_pct}% is below — decaying transient (likely the coordinator's own gate run); not counted as a signal (#943).`);
159
341
  }
160
342
 
161
- // ---------------------------------------------------------------------------
162
- // Zombie signal (#178): null = feature disabled (zombieThresholdMin absent) → no rule fires
163
- // Escalates to warn only when BOTH zombie_processes_count >= 1 AND
164
- // claude_processes_count is elevated (>= concWarn or > 0 with zombies).
165
- // ---------------------------------------------------------------------------
166
- const { zombie_processes_count } = snapshot;
167
- if (zombie_processes_count !== null && zombie_processes_count !== undefined && zombie_processes_count >= 1) {
168
- const claudeElevated =
169
- claude_processes_count !== null &&
170
- claude_processes_count !== undefined &&
171
- claude_processes_count > 0;
172
- if (claudeElevated) {
173
- verdict = bumpVerdict(verdict, 'warn');
174
- reasons.push(`${zombie_processes_count} zombie Claude/Node process(es) detected (age > ${thresholds['zombie-threshold-min'] ?? 30} min, idle CPU) with ${claude_processes_count} Claude process(es) running — consider sweeping stale sessions.`);
175
- }
343
+ // --- Concurrency axis: registry sessions, not raw process count -----------
344
+ const conc = concurrencySignal(snapshot, concWarn);
345
+ if (conc && conc.soft) {
346
+ softSignals.push('concurrency');
347
+ reasons.push(conc.reason);
176
348
  }
177
349
 
178
- // ---------------------------------------------------------------------------
179
- // Swap signal (macOS / Linux only; null = signal unavailable no rule fires)
180
- // On macOS, swap is accumulated over time and is not a real-time pressure
181
- // indicator Activity Monitor reports it under "Used" but classifies system
182
- // health via memory_pressure, not swap volume. So when pressure is healthy,
183
- // swap signal is treated as informational only (downgrades critical→warn).
184
- // ---------------------------------------------------------------------------
185
- const { swap_used_mb } = snapshot;
350
+ // --- Swap: only meaningful when the memory signal is NOT healthy ----------
351
+ // On macOS swap accumulates over a machine's uptime and is not a real-time
352
+ // pressure indicator, so a large cumulative figure under healthy pressure is
353
+ // history, not load. Observed on the reference host 2026-08-21: 6884 MB swap
354
+ // used with memory_pressure reporting 35% free and the machine responsive.
186
355
  if (swap_used_mb !== null && swap_used_mb !== undefined) {
187
- if (swap_used_mb > 3072 && !macosPressureHealthy) {
188
- verdict = bumpVerdict(verdict, 'critical');
189
- cap = 0;
190
- reasons.push(`Swap usage ${swap_used_mb} MB above critical threshold 3072 MB — recommend coordinator-direct (0 agents).`);
191
- } else if (swap_used_mb > 2048 && !macosPressureHealthy) {
192
- const newVerdict = bumpVerdict(verdict, 'degraded');
193
- if (newVerdict !== verdict) {
194
- verdict = newVerdict;
195
- cap = cap === null ? 2 : Math.min(cap, 2);
196
- }
197
- reasons.push(`Swap usage ${swap_used_mb} MB in degraded range (2048..3072 MB) — capping agents-per-wave at 2.`);
198
- } else if (swap_used_mb > 1024 && !macosPressureHealthy) {
199
- const newVerdict = bumpVerdict(verdict, 'warn');
200
- if (newVerdict !== verdict) {
201
- verdict = newVerdict;
202
- cap = cap === null ? 2 : Math.min(cap, 2);
356
+ if (memoryHealthy) {
357
+ if (swap_used_mb > SWAP_SOFT_MB) {
358
+ reasons.push(`info: swap usage ${swap_used_mb} MB present but the memory signal is healthy — cumulative, not live pressure; not counted as a signal.`);
203
359
  }
204
- reasons.push(`Swap usage ${swap_used_mb} MB in warn range (1024..2048 MB) — capping agents-per-wave at 2.`);
205
- } else if (swap_used_mb > 1024 && macosPressureHealthy) {
206
- // Informational only: pressure says system is healthy, swap is historical.
207
- reasons.push(`Swap usage ${swap_used_mb} MB present but macOS memory_pressure healthy — treating as informational (no cap).`);
360
+ } else if (swap_used_mb > SWAP_HARD_MB) {
361
+ hardSignals.push('swap');
362
+ reasons.push(`Swap usage ${swap_used_mb} MB above hard threshold ${SWAP_HARD_MB} MB while memory is under pressure.`);
363
+ } else if (swap_used_mb > SWAP_SOFT_MB) {
364
+ softSignals.push('swap');
365
+ reasons.push(`Swap usage ${swap_used_mb} MB above ${SWAP_SOFT_MB} MB while memory is under pressure.`);
208
366
  }
209
367
  }
210
368
 
211
- // ---------------------------------------------------------------------------
212
- // macOS memory_pressure signal (null = signal unavailable → no rule fires)
213
- // (memory_pressure_pct_free already destructured at the top of this fn)
214
- // ---------------------------------------------------------------------------
215
- if (memory_pressure_pct_free !== null && memory_pressure_pct_free !== undefined) {
216
- if (memory_pressure_pct_free < 5) {
217
- verdict = bumpVerdict(verdict, 'critical');
218
- cap = 0;
219
- reasons.push(`macOS memory_pressure free ${memory_pressure_pct_free}% below critical threshold 5% — recommend coordinator-direct (0 agents).`);
220
- } else if (memory_pressure_pct_free < 15) {
221
- const newVerdict = bumpVerdict(verdict, 'degraded');
222
- if (newVerdict !== verdict) {
223
- verdict = newVerdict;
224
- cap = cap === null ? 2 : Math.min(cap, 2);
225
- }
226
- reasons.push(`macOS memory_pressure free ${memory_pressure_pct_free}% in degraded range (5..15%) capping agents-per-wave at 2.`);
227
- } else if (memory_pressure_pct_free < 30) {
228
- const newVerdict = bumpVerdict(verdict, 'warn');
229
- if (newVerdict !== verdict) {
230
- verdict = newVerdict;
231
- cap = cap === null ? 2 : Math.min(cap, 2);
232
- }
233
- reasons.push(`macOS memory_pressure free ${memory_pressure_pct_free}% in warn range (15..30%) capping agents-per-wave at 2.`);
369
+ // --- Zombies (#178) — INFORMATIONAL ONLY ----------------------------------
370
+ //
371
+ // A stale-but-idle process holds RAM without doing work, so it is worth
372
+ // reporting. It is NOT a capacity signal, and #1089's first live run proved
373
+ // why counting it as one re-opens the bug this module just closed:
374
+ //
375
+ // VERDICT: warn | cap 2 | soft: ["cpu", "zombies"]
376
+ //
377
+ // Zombies are, by their own definition, idle (CPU <= 1%)they are not
378
+ // causing the CPU load they were pairing with. And they are a STANDING
379
+ // condition on a developer host: three measurements minutes apart on the
380
+ // reference machine read 6, 13 and 9. A signal that is essentially always
381
+ // present is not a second opinion; pairing it with any other axis silently
382
+ // restores the one-signal cap under a two-signal name.
383
+ //
384
+ // So it reports and never counts. This also aligns the code with the rule
385
+ // text it always claimed to implement — `.claude/rules/host-resources.md`
386
+ // HR-104: "sweeping stale sessions is housekeeping advice, not a reason to
387
+ // shrink a wave."
388
+ //
389
+ // Still gated on a live peer/process context so a lone leftover on an
390
+ // otherwise idle host stays quiet.
391
+ if (zombie_processes_count !== null && zombie_processes_count !== undefined && zombie_processes_count >= 1) {
392
+ const liveContext =
393
+ (peer_sessions_count !== null && peer_sessions_count !== undefined && peer_sessions_count > 0) ||
394
+ (claude_processes_count !== null && claude_processes_count !== undefined && claude_processes_count > 0);
395
+ if (liveContext) {
396
+ reasons.push(`info: ${zombie_processes_count} zombie Claude/Node process(es) detected (age > ${thresholds['zombie-threshold-min'] ?? 30} min, idle CPU) — consider sweeping stale sessions; not counted as a capacity signal.`);
234
397
  }
235
398
  }
236
399
 
237
- // Ensure cap aligns with final verdict when critical was triggered by swap/memory_pressure
238
- // (the bumpVerdict path sets cap=0 inline for critical, but re-affirm for safety)
239
- if (verdict === 'critical') {
400
+ // --- Two-signal rule ------------------------------------------------------
401
+ // One hard signal critical. Two independent soft signals warn. A single
402
+ // soft signal is reported and caps nothing: that is the whole point of #1089,
403
+ // because each axis alone is noisy enough that their union fired on 99.0% of
404
+ // measured session starts.
405
+ let verdict = 'green';
406
+ let cap = null;
407
+ if (hardSignals.length > 0) {
408
+ verdict = 'critical';
240
409
  cap = 0;
410
+ reasons.push(`Hard signal (${hardSignals.join(' + ')}) — recommend coordinator-direct (0 agents).`);
411
+ } else if (softSignals.length >= 2) {
412
+ verdict = 'warn';
413
+ cap = 2;
414
+ reasons.push(`Two independent signals agree (${softSignals.join(' + ')}) — capping agents-per-wave at 2.`);
415
+ } else if (softSignals.length === 1) {
416
+ reasons.push(`info: one signal (${softSignals[0]}) is elevated but no second signal agrees — reporting only, no cap (two-signal rule, #1089).`);
241
417
  }
242
418
 
243
419
  // ---------------------------------------------------------------------------
@@ -252,5 +428,10 @@ export function evaluate(snapshot, thresholds, options = {}) {
252
428
  cap = cap === null ? resolvedApwCap : Math.min(cap, resolvedApwCap);
253
429
  }
254
430
 
255
- return { verdict, reasons, recommended_agents_per_wave_cap: cap };
431
+ return {
432
+ verdict,
433
+ reasons,
434
+ recommended_agents_per_wave_cap: cap,
435
+ signals: { hard: hardSignals, soft: softSignals },
436
+ };
256
437
  }
@@ -298,3 +298,38 @@ export async function ramAvailableGb() {
298
298
  const output = await runCommand('vm_stat', []);
299
299
  return parseVmStatAvailableGb(output);
300
300
  }
301
+
302
+ /**
303
+ * Count LIVE PEER SESSIONS on this host from the session registry (#1089).
304
+ *
305
+ * This is the signal `concurrent-sessions-warn` was always named for. Until
306
+ * #1089 the threshold was compared against `claude_processes_count` instead —
307
+ * a measured 6x unit error (median processes:sessions = 6.0 over 1461 paired
308
+ * samples), which is why a threshold of 5 fired on 93.6% of session starts
309
+ * while the same threshold against the real count fires on 4.2%.
310
+ *
311
+ * Semantics mirror `detectPeers()` exactly: EXCLUDES the calling session and
312
+ * counts only entries whose `last_heartbeat` is within `freshnessMin` minutes.
313
+ * That matches the `peer_count` field already recorded on
314
+ * `orchestrator.session.started` events, so historical telemetry and live
315
+ * probes are denominated identically.
316
+ *
317
+ * Best-effort: returns null when the registry is unreadable (sandboxed test
318
+ * environments, a foreign `SO_SESSION_REGISTRY_DIR`, first run before any
319
+ * registration). Consumers treat null as "unknown" and fall back to the
320
+ * rescaled process count.
321
+ *
322
+ * @param {object} [opts]
323
+ * @param {string|null} [opts.sessionId] — own session id, excluded from the count
324
+ * @param {number} [opts.freshnessMin=15]
325
+ * @returns {Promise<number|null>}
326
+ */
327
+ export async function peerSessionsCount({ sessionId = null, freshnessMin = 15 } = {}) {
328
+ try {
329
+ const { detectPeers } = await import('../session-registry.mjs');
330
+ const peers = await detectPeers({ sessionId, freshnessMin });
331
+ return Array.isArray(peers) ? peers.length : null;
332
+ } catch {
333
+ return null;
334
+ }
335
+ }