session-orchestrator 3.16.0 → 3.19.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 (220) 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/030-wave-execution.mdc +17 -1
  5. package/CHANGELOG.md +130 -412
  6. package/README.md +17 -12
  7. package/SECURITY.md +190 -27
  8. package/agents/AGENTS.md +20 -3
  9. package/agents/code-implementer.md +6 -6
  10. package/agents/db-specialist.md +1 -1
  11. package/agents/qa-strategist.md +31 -6
  12. package/agents/schemas/qa-strategist.schema.json +27 -0
  13. package/agents/schemas/test-writer.schema.json +60 -2
  14. package/agents/security-reviewer.md +1 -1
  15. package/agents/session-reviewer.md +1 -1
  16. package/agents/test-writer.md +29 -10
  17. package/agents/ui-developer.md +1 -1
  18. package/commands/contract-version-bump.md +28 -0
  19. package/commands/portfolio.md +1 -1
  20. package/docs/README.md +2 -1
  21. package/docs/USER-GUIDE.md +8 -3
  22. package/docs/ci-setup.md +121 -7
  23. package/docs/codex-setup.md +1 -1
  24. package/docs/components.md +7 -7
  25. package/docs/cursor-setup.md +22 -9
  26. package/docs/events-schema.md +5 -1
  27. package/docs/instruction-delivery.md +444 -0
  28. package/docs/pi-setup.md +1 -1
  29. package/docs/rule-authoring.md +58 -9
  30. package/docs/session-config-reference.md +306 -6
  31. package/docs/session-config-template.md +66 -3
  32. package/docs/telemetry/telemetry-claims.md +204 -0
  33. package/docs/telemetry.md +158 -0
  34. package/hooks/_lib/guard-source-loader.mjs +467 -0
  35. package/hooks/_lib/lock-bootstrap.mjs +21 -0
  36. package/hooks/_lib/vcs-create-matcher.mjs +119 -0
  37. package/hooks/config-protection.mjs +0 -0
  38. package/hooks/enforce-commands.mjs +10 -2
  39. package/hooks/hooks-codex.json +1 -1
  40. package/hooks/hooks-cursor.json +11 -2
  41. package/hooks/hooks-pi.json +10 -0
  42. package/hooks/hooks.json +21 -1
  43. package/hooks/on-session-end.mjs +178 -18
  44. package/hooks/on-session-start.mjs +23 -0
  45. package/hooks/post-bash-write-verify.mjs +977 -0
  46. package/hooks/post-subagent-discovery-validator.mjs +256 -41
  47. package/hooks/pre-bash-destructive-guard.mjs +525 -160
  48. package/hooks/pre-bash-issue-budget.mjs +167 -0
  49. package/hooks/pre-bash-sessions-ledger-guard.mjs +627 -0
  50. package/hooks/pre-bash-templates-first.mjs +96 -63
  51. package/hooks/skill-invocation-telemetry.mjs +109 -10
  52. package/hooks/subagent-telemetry.mjs +527 -37
  53. package/package.json +16 -3
  54. package/pi/prompts/contract-version-bump.md +12 -0
  55. package/rules/README.md +32 -0
  56. package/scripts/archive-closed-prds.mjs +12 -22
  57. package/scripts/autopilot-multi.mjs +103 -20
  58. package/scripts/backfill-abandoned-sessions.mjs +160 -4
  59. package/scripts/check-doc-consistency.sh +17 -1
  60. package/scripts/compute-grounding-injection.sh +18 -3
  61. package/scripts/dialectic-deriver.mjs +7 -2
  62. package/scripts/eval-session.mjs +50 -9
  63. package/scripts/fleet-instruction-scan.mjs +141 -0
  64. package/scripts/lib/auto-dialectic.mjs +11 -2
  65. package/scripts/lib/auto-dream.mjs +16 -5
  66. package/scripts/lib/autopilot/mr-draft.mjs +31 -1
  67. package/scripts/lib/autopilot/worktree-pipeline.mjs +113 -5
  68. package/scripts/lib/backlog-scan.mjs +39 -6
  69. package/scripts/lib/blocked-commands-policy.mjs +340 -0
  70. package/scripts/lib/build-live-signals.mjs +7 -4
  71. package/scripts/lib/ci-status-banner.mjs +75 -12
  72. package/scripts/lib/claude-md-budget-lint.mjs +283 -34
  73. package/scripts/lib/command-blocker.mjs +1013 -58
  74. package/scripts/lib/config/config-protection.mjs +2 -1
  75. package/scripts/lib/config/context-coverage.mjs +82 -0
  76. package/scripts/lib/config/drift-check.mjs +9 -1
  77. package/scripts/lib/config/gitlab-portfolio.mjs +1 -1
  78. package/scripts/lib/config/issue-budget.mjs +123 -0
  79. package/scripts/lib/config/moc-staleness.mjs +98 -0
  80. package/scripts/lib/config/reconcile.mjs +21 -0
  81. package/scripts/lib/config/section-extractor.mjs +121 -1
  82. package/scripts/lib/config/worktree-orphans.mjs +138 -0
  83. package/scripts/lib/config-schema.mjs +23 -3
  84. package/scripts/lib/config.mjs +32 -0
  85. package/scripts/lib/context-coverage-banner.mjs +223 -0
  86. package/scripts/lib/convergence-monitor.mjs +49 -3
  87. package/scripts/lib/description-surface.mjs +535 -0
  88. package/scripts/lib/dispatcher/enumerate.mjs +136 -30
  89. package/scripts/lib/dispatcher/rank.mjs +22 -8
  90. package/scripts/lib/ecosystem-wizard/config-writer.mjs +26 -24
  91. package/scripts/lib/ecosystem-wizard/wizard-prompt.mjs +1 -1
  92. package/scripts/lib/eval/engine.mjs +47 -5
  93. package/scripts/lib/events.mjs +59 -7
  94. package/scripts/lib/evolve/autonomy-verdict.mjs +5 -0
  95. package/scripts/lib/evolve/autopilot-effectiveness.mjs +54 -7
  96. package/scripts/lib/gates/gate-full.mjs +15 -3
  97. package/scripts/lib/gates/gate-helpers.mjs +132 -6
  98. package/scripts/lib/gitlab-ops/stale-mr-sweep.mjs +28 -8
  99. package/scripts/lib/gitlab-portfolio/aggregator.mjs +8 -2
  100. package/scripts/lib/gitlab-portfolio/cli.mjs +1 -1
  101. package/scripts/lib/handover-gate.mjs +7 -3
  102. package/scripts/lib/harness-audit/categories/category4.mjs +22 -5
  103. package/scripts/lib/instruction-budget-guard.mjs +402 -51
  104. package/scripts/lib/io.mjs +345 -10
  105. package/scripts/lib/issue-budget.mjs +269 -0
  106. package/scripts/lib/issue-close-strip-labels.mjs +39 -9
  107. package/scripts/lib/label-scope.mjs +47 -0
  108. package/scripts/lib/learnings/schema.mjs +43 -3
  109. package/scripts/lib/lock-reaper.mjs +1 -2
  110. package/scripts/lib/memory-proposals/schema.mjs +36 -1
  111. package/scripts/lib/moc-staleness-banner.mjs +267 -0
  112. package/scripts/lib/peer-discovery.mjs +645 -0
  113. package/scripts/lib/pi-hook-bridge.mjs +146 -17
  114. package/scripts/lib/product-repo-detect.mjs +9 -8
  115. package/scripts/lib/project-hygiene.mjs +432 -0
  116. package/scripts/lib/quality-gate.mjs +167 -0
  117. package/scripts/lib/recommendations-v0.mjs +1 -1
  118. package/scripts/lib/reconcile/eligibility.mjs +1 -1
  119. package/scripts/lib/reconcile/emitter.mjs +23 -4
  120. package/scripts/lib/reconcile/engine.mjs +147 -39
  121. package/scripts/lib/reconcile/idempotency.mjs +114 -14
  122. package/scripts/lib/reconcile-nudge-banner.mjs +65 -9
  123. package/scripts/lib/resource-probe/evaluate.mjs +70 -4
  124. package/scripts/lib/resource-probe.mjs +19 -0
  125. package/scripts/lib/rule-loader.mjs +6 -0
  126. package/scripts/lib/scope-baseline.mjs +564 -0
  127. package/scripts/lib/scope-gate.mjs +399 -98
  128. package/scripts/lib/session-close-backfill.mjs +61 -6
  129. package/scripts/lib/session-end/phase-skip.mjs +1 -0
  130. package/scripts/lib/session-end/worktree-orphan-sweep.mjs +252 -0
  131. package/scripts/lib/session-id.mjs +221 -41
  132. package/scripts/lib/session-lock.mjs +304 -6
  133. package/scripts/lib/session-schema/constants.mjs +22 -3
  134. package/scripts/lib/session-schema/filters.mjs +88 -0
  135. package/scripts/lib/session-schema/validator.mjs +16 -0
  136. package/scripts/lib/session-schema.mjs +1 -0
  137. package/scripts/lib/sessions-integrity-banner.mjs +294 -0
  138. package/scripts/lib/sessions-staleness-banner.mjs +121 -12
  139. package/scripts/lib/skill-evolution/idempotency.mjs +135 -16
  140. package/scripts/lib/skill-evolution/mr-opener.mjs +9 -1
  141. package/scripts/lib/skill-health/join.mjs +35 -9
  142. package/scripts/lib/spiral-carryover.mjs +142 -30
  143. package/scripts/lib/state-md/mission-status.mjs +53 -3
  144. package/scripts/lib/subagents-schema.mjs +43 -9
  145. package/scripts/lib/telemetry/anon-id.mjs +141 -0
  146. package/scripts/lib/telemetry/consent.mjs +299 -0
  147. package/scripts/lib/telemetry/paths.mjs +27 -0
  148. package/scripts/lib/telemetry/queue.mjs +287 -0
  149. package/scripts/lib/telemetry/schema.mjs +384 -0
  150. package/scripts/lib/telemetry/sync.mjs +312 -0
  151. package/scripts/lib/test-runner/issue-reconcile.mjs +53 -13
  152. package/scripts/lib/tests-src-ratio.mjs +484 -0
  153. package/scripts/lib/validate/check-agents.mjs +56 -0
  154. package/scripts/lib/validate/check-hooks-symmetry.mjs +244 -10
  155. package/scripts/lib/validate/check-rules.mjs +217 -35
  156. package/scripts/lib/validate/check-test-value-bans.mjs +782 -0
  157. package/scripts/lib/validate/check-unicode-safety.mjs +1 -0
  158. package/scripts/lib/validate-vendored-rules.mjs +10 -2
  159. package/scripts/lib/vault-archive.mjs +17 -2
  160. package/scripts/lib/vault-backfill/glab.mjs +8 -0
  161. package/scripts/lib/vault-mirror/process.mjs +30 -0
  162. package/scripts/lib/vault-mirror/render-sessions.mjs +293 -36
  163. package/scripts/lib/vault-status/board-writer.mjs +63 -5
  164. package/scripts/lib/vault-status/narrative-mirror.mjs +13 -7
  165. package/scripts/lib/vcs-repo-spec.mjs +362 -0
  166. package/scripts/lib/wave-resource-gate.mjs +115 -11
  167. package/scripts/lib/worktree/listing.mjs +44 -7
  168. package/scripts/mcp-server.sh +32 -6
  169. package/scripts/measure-context-overhead.sh +151 -0
  170. package/scripts/memory-propose.mjs +72 -9
  171. package/scripts/print-applicable-rules.mjs +51 -12
  172. package/scripts/release.mjs +534 -0
  173. package/scripts/run-quality-gate.mjs +123 -5
  174. package/scripts/telemetry.mjs +250 -0
  175. package/scripts/validate-wave-scope.mjs +182 -17
  176. package/scripts/vault-integration-watcher.mjs +32 -10
  177. package/skills/_shared/config-reading.md +2 -2
  178. package/skills/bootstrap/fast-template.md +1 -1
  179. package/skills/claude-md-drift-check/checker.mjs +145 -28
  180. package/skills/contract-version-bump/SKILL.md +219 -0
  181. package/skills/discovery/SKILL.md +4 -4
  182. package/skills/discovery/issue-templates.md +11 -11
  183. package/skills/discovery/probes-audit.md +1 -1
  184. package/skills/discovery/probes-feature.md +1 -1
  185. package/skills/discovery/probes-session.md +26 -5
  186. package/skills/ecosystem-health/SKILL.md +1 -1
  187. package/skills/ecosystem-health/wizard.md +4 -4
  188. package/skills/evolve/SKILL.md +1 -0
  189. package/skills/gitlab-ops/SKILL.md +20 -12
  190. package/skills/gitlab-portfolio/SKILL.md +2 -2
  191. package/skills/hook-development/SKILL.md +1 -1
  192. package/skills/mode-selector/SKILL.md +1 -1
  193. package/skills/npm-publish/SKILL.md +97 -0
  194. package/skills/plan/SKILL.md +5 -5
  195. package/skills/plan/mode-feature.md +4 -4
  196. package/skills/plan/mode-new.md +10 -10
  197. package/skills/plan/mode-retro.md +1 -1
  198. package/skills/quality-gates/SKILL.md +1 -1
  199. package/skills/reconcile/SKILL.md +21 -4
  200. package/skills/session-end/SKILL.md +108 -14
  201. package/skills/session-end/discovery-scan.md +4 -2
  202. package/skills/session-end/drift-operations.md +4 -4
  203. package/skills/session-end/metrics-collection.md +13 -0
  204. package/skills/session-end/phase-3-2-docs-verification.md +1 -1
  205. package/skills/session-end/phase-3-6-tail.md +2 -1
  206. package/skills/session-end/plan-verification.md +5 -2
  207. package/skills/session-end/vault-operations.md +1 -1
  208. package/skills/session-end/verification-checklist.md +1 -1
  209. package/skills/session-plan/SKILL.md +6 -2
  210. package/skills/session-plan/wave-template.md +2 -0
  211. package/skills/session-start/SKILL.md +149 -7
  212. package/skills/session-start/phase-4-5-resource-health.md +15 -2
  213. package/skills/test-runner/SKILL.md +2 -2
  214. package/skills/vault-sync/SKILL.md +1 -1
  215. package/skills/vault-sync/package-lock.json +3 -3
  216. package/skills/vault-sync/validator.mjs +228 -40
  217. package/skills/wave-executor/SKILL.md +5 -2
  218. package/skills/wave-executor/circuit-breaker.md +2 -0
  219. package/skills/wave-executor/wave-loop.md +163 -10
  220. package/templates/_shared/loop.md +4 -4
@@ -42,11 +42,31 @@
42
42
  * - checkPeerStateMd throws (it shouldn't) → contribute no state-md entry.
43
43
  * - Worst case returns `{ peers: [] }`.
44
44
  *
45
+ * ---------------------------------------------------------------------------
46
+ * Second export: checkLiveForeignSession (Issue #908)
47
+ * ---------------------------------------------------------------------------
48
+ *
49
+ * findPeers is the raw union — powerful, but every consumer so far had to
50
+ * re-derive the same three non-obvious rules to turn it into the one question
51
+ * that is actually asked ("is a live FOREIGN session running in <repo>?"):
52
+ * which id-space self-excludes on which surface, which liveness signal is
53
+ * canonical, and which `source` values may be counted. Getting any of the
54
+ * three wrong is silent — you get a plausible boolean that is simply false.
55
+ * checkLiveForeignSession answers the question once, so no caller has to.
56
+ * See its own JSDoc for the decision record.
57
+ *
45
58
  * @module peer-discovery
46
59
  */
47
60
 
61
+ import fs from 'node:fs';
62
+ import path from 'node:path';
63
+ import { fileURLToPath } from 'node:url';
64
+ import { parseArgs } from 'node:util';
65
+
48
66
  import { discoverActiveSessions } from './session-discovery.mjs';
67
+ import { readLock, isLockLive, LOCK_PATH } from './session-lock.mjs';
49
68
  import { checkPeerStateMd } from './state-md-peer-guard.mjs';
69
+ import { listWorktreesChecked } from './worktree/listing.mjs';
50
70
 
51
71
  /** Closed enum of provenance sources. */
52
72
  const SOURCE_DISCOVERED = 'discovered'; // lock + registry unified (irreversibly merged upstream)
@@ -198,3 +218,628 @@ export async function findPeers(repoRoot, opts = {}) {
198
218
 
199
219
  return { peers };
200
220
  }
221
+
222
+ // ===========================================================================
223
+ // checkLiveForeignSession (Issue #908) — "is a live FOREIGN session running
224
+ // in this repo?" as a single mechanical verdict.
225
+ // ===========================================================================
226
+
227
+ /** Verdict `probe` values — which measurement actually ran. */
228
+ const PROBE_NONE = 'none'; // pre-check refused to measure (fail-safe verdict)
229
+ const PROBE_LOCK_ONLY = 'lock-only'; // cheap path: one sync lock read, no git
230
+ const PROBE_FULL = 'full'; // full path: findPeers (worktrees + registry + STATE.md)
231
+ const PROBE_FULL_DEGRADED = 'full-degraded'; // full path ran but returned demonstrably incomplete data
232
+
233
+ /**
234
+ * Resolve a path to its canonical form, falling back to the merely-resolved
235
+ * form when the path does not exist (realpathSync throws on ENOENT). Never
236
+ * throws — a non-canonical comparison is still a useful comparison.
237
+ *
238
+ * @param {string} p
239
+ * @returns {string}
240
+ */
241
+ function _realpathOrResolved(p) {
242
+ const resolved = path.resolve(p);
243
+ try {
244
+ return fs.realpathSync(resolved);
245
+ } catch {
246
+ return resolved;
247
+ }
248
+ }
249
+
250
+ /**
251
+ * Nearest ancestor of `dirAbs` (inclusive) that holds a `.git` entry — i.e. the
252
+ * working-copy root `dirAbs` belongs to. `null` when `dirAbs` is not inside any
253
+ * working copy.
254
+ *
255
+ * DELIBERATELY NOT `git rev-parse --show-toplevel`. This runs on the probe-
256
+ * SELECTION path, whose whole justification is that the `'lock-only'` branch
257
+ * touches no git process; a spawn here would re-introduce exactly the cost the
258
+ * two-probe split exists to avoid, and it would pay it on EVERY call — including
259
+ * the calls that then take the cheap branch. The cases `rev-parse` would decide
260
+ * differently are `GIT_DIR`/`GIT_WORK_TREE` env overrides and `core.worktree`
261
+ * indirection; no call site in this repo produces them, and when they do occur
262
+ * the disagreement costs at most a probe-variant choice, never a wrong verdict
263
+ * direction (the `'lock-only'` branch reads the target's OWN lock either way).
264
+ *
265
+ * `.git` is a directory in a normal checkout and a FILE in a linked worktree.
266
+ * `existsSync` accepts both, which is what we want: a linked worktree's root IS
267
+ * its own working copy (see the sibling-worktree note on `_isOwnWorkingCopy`).
268
+ *
269
+ * @param {string} dirAbs
270
+ * @returns {string|null}
271
+ */
272
+ function _workingCopyRootOf(dirAbs) {
273
+ // Bounded by path depth; one stat syscall per level.
274
+ let cur = dirAbs;
275
+ for (;;) {
276
+ if (fs.existsSync(path.join(cur, '.git'))) return cur;
277
+ const parent = path.dirname(cur);
278
+ if (parent === cur) return null; // reached the filesystem root
279
+ cur = parent;
280
+ }
281
+ }
282
+
283
+ /**
284
+ * True when `cwdAbs` and `repoRootAbs` denote the SAME working copy.
285
+ *
286
+ * Containment alone is NOT the test. The full probe enumerates peers through
287
+ * `findPeers` → `discoverActiveSessions` → `listWorktrees`, and `listWorktrees`
288
+ * runs `git worktree list` in `process.cwd()` (worktree/listing.mjs) — it can
289
+ * only ever measure the working copy the PROCESS sits in. Selecting it for a
290
+ * repo that merely CONTAINS (or is contained by) the cwd measures a different
291
+ * repository and then reports THAT repository's emptiness as the target's
292
+ * verdict — a successful measurement of the wrong thing, which the fail-safe
293
+ * direction cannot catch because nothing failed.
294
+ *
295
+ * Concretely reachable: a portfolio workspace directory (`<home>/Projects/
296
+ * <workspace>/`) is commonly a git repo with its own `.orchestrator/` state AND
297
+ * an ancestor of every product repo checked out beneath it. Under pure
298
+ * containment a probe of that parent from a child repo took the full path and
299
+ * answered `live: false` from the CHILD's worktree list.
300
+ *
301
+ * So: same directory ⇒ same working copy (no identity question to ask);
302
+ * otherwise containment is necessary but must be confirmed by IDENTITY — the
303
+ * working-copy root the cwd belongs to has to BE `repoRootAbs`.
304
+ *
305
+ * A SIBLING worktree (`<base>/<repo>-<sessionId>/`) is deliberately NOT "the
306
+ * same working copy": it is a different checkout with its own lock, so a probe
307
+ * against it is a foreign-repo probe.
308
+ *
309
+ * @param {string} repoRootAbs
310
+ * @param {string} cwdAbs
311
+ * @returns {boolean}
312
+ */
313
+ function _isOwnWorkingCopy(repoRootAbs, cwdAbs) {
314
+ if (repoRootAbs === cwdAbs) return true;
315
+
316
+ const contained = cwdAbs.startsWith(repoRootAbs + path.sep)
317
+ || repoRootAbs.startsWith(cwdAbs + path.sep);
318
+ if (!contained) return false;
319
+
320
+ const cwdRoot = _workingCopyRootOf(cwdAbs);
321
+ if (cwdRoot === null) return false; // cwd is in no working copy → nothing to own
322
+ return _realpathOrResolved(cwdRoot) === repoRootAbs;
323
+ }
324
+
325
+ /**
326
+ * Normalise a raw schema-v2 lock body into the verdict's `peer` shape.
327
+ *
328
+ * @param {object} lock Parsed lock body.
329
+ * @param {string} worktreePath Absolute path the lock was read from.
330
+ * @param {number} nowMs
331
+ * @returns {object}
332
+ */
333
+ function _peerFromLockBody(lock, worktreePath, nowMs) {
334
+ const ageHours = _ageHoursFrom(lock.started_at, nowMs);
335
+ return {
336
+ source: SOURCE_DISCOVERED,
337
+ sessionId: typeof lock.session_id === 'string' ? lock.session_id : null,
338
+ semanticSessionId: typeof lock.semantic_session_id === 'string' ? lock.semantic_session_id : null,
339
+ mode: lock.mode ?? null,
340
+ host: lock.host ?? null,
341
+ pid: typeof lock.pid === 'number' ? lock.pid : null,
342
+ startedAt: typeof lock.started_at === 'string' ? lock.started_at : null,
343
+ lastHeartbeat: typeof lock.last_heartbeat === 'string' ? lock.last_heartbeat : null,
344
+ ageHours: ageHours === undefined ? null : ageHours,
345
+ worktreePath,
346
+ };
347
+ }
348
+
349
+ /**
350
+ * Normalise a findPeers `source: 'discovered'` entry into the verdict's `peer`
351
+ * shape. Fields that surface cannot supply are emitted as null rather than
352
+ * omitted, so consumers never have to branch on shape.
353
+ *
354
+ * @param {object} p
355
+ * @returns {object}
356
+ */
357
+ function _peerFromFindPeersEntry(p) {
358
+ return {
359
+ source: SOURCE_DISCOVERED,
360
+ sessionId: p.sessionId,
361
+ semanticSessionId: null,
362
+ mode: p.mode ?? null,
363
+ host: p.host ?? null,
364
+ pid: typeof p.pid === 'number' ? p.pid : null,
365
+ startedAt: null, // discoverActiveSessions does not thread startedAt through findPeers
366
+ lastHeartbeat: null,
367
+ ageHours: typeof p.ageHours === 'number' ? p.ageHours : null,
368
+ worktreePath: p.worktreePath ?? null,
369
+ };
370
+ }
371
+
372
+ /**
373
+ * Build a verdict object. Central so every return path emits the same shape.
374
+ *
375
+ * @param {boolean} live
376
+ * @param {string} reason
377
+ * @param {string} probe
378
+ * @param {{peerCount?: number, peer?: object|null}} [extra]
379
+ * @returns {{live: boolean, reason: string, probe: string, peerCount: number, peer: object|null}}
380
+ */
381
+ function _verdict(live, reason, probe, extra = {}) {
382
+ return {
383
+ live,
384
+ reason,
385
+ probe,
386
+ peerCount: extra.peerCount ?? 0,
387
+ peer: extra.peer ?? null,
388
+ };
389
+ }
390
+
391
+ /**
392
+ * Timeout for the #919.3 git-surface confirmation probe. Mirrors
393
+ * session-discovery's DEFAULT_DISCOVERY_TIMEOUT_MS: a hung git must degrade
394
+ * the verdict (fail-safe), never hang it.
395
+ */
396
+ const WORKTREE_CONFIRM_TIMEOUT_MS = 2000;
397
+
398
+ /**
399
+ * #919.3 — confirm that `git worktree list` can actually RUN in this process's
400
+ * working copy. Called ONLY on the full path's residual branch (discovered
401
+ * surface produced nothing AND no live own lock exists to canary against —
402
+ * see the RESIDUAL GAP note on `checkLiveForeignSession`).
403
+ *
404
+ * Honours the same DI seam findPeers forwards (`opts.listWorktreesImpl`): a
405
+ * seam that RESOLVES (any value) proves the surface functional; a seam that
406
+ * throws/rejects reproduces the git failure. Without a seam,
407
+ * `listWorktreesChecked()` supplies the real signal (`ok: false` = git did not
408
+ * run — the exact state the bare `listWorktrees()` swallows into `[]`).
409
+ *
410
+ * Raced against a 2s timeout: a hung git resolves to `false` (not confirmed ⇒
411
+ * fail safe). Never throws; never rejects.
412
+ *
413
+ * @param {object} opts The `checkLiveForeignSession` opts (for the DI seam).
414
+ * @returns {Promise<boolean>} true ⇔ `git worktree list` demonstrably ran.
415
+ */
416
+ async function _confirmWorktreeListRan(opts) {
417
+ let timer;
418
+ try {
419
+ const probe = typeof opts.listWorktreesImpl === 'function'
420
+ ? Promise.resolve().then(() => opts.listWorktreesImpl()).then(() => true, () => false)
421
+ : listWorktreesChecked().then((r) => r?.ok === true, () => false);
422
+ const timeout = new Promise((resolve) => {
423
+ timer = setTimeout(() => resolve(false), WORKTREE_CONFIRM_TIMEOUT_MS);
424
+ if (typeof timer?.unref === 'function') timer.unref();
425
+ });
426
+ return (await Promise.race([probe, timeout])) === true;
427
+ } catch {
428
+ return false; // unmeasurable ⇒ not confirmed ⇒ the caller fails safe
429
+ } finally {
430
+ if (timer !== undefined) clearTimeout(timer);
431
+ }
432
+ }
433
+
434
+ /**
435
+ * checkLiveForeignSession — mechanically decide whether a LIVE FOREIGN session
436
+ * is running in `repoRoot` (Issue #908, Baustein 3).
437
+ *
438
+ * Consumer contract: when this returns `live: true`, every fact harvested from
439
+ * that repo is volatile BY DEFINITION — regardless of how recently it was
440
+ * measured — because someone else may be committing into it right now. The
441
+ * staleness-annotation consumer reads this as "threshold 0: state facts from
442
+ * this repo as ASSERTED, not as established".
443
+ *
444
+ * ── Two probes, auto-selected (no knob the caller can set wrong) ────────────
445
+ *
446
+ * The right measurement depends on WHOSE working copy `repoRoot` is, and that
447
+ * is derivable — so it is derived here rather than delegated to a parameter:
448
+ *
449
+ * - `repoRoot` is MY working copy (same directory, or a containment relation
450
+ * CONFIRMED BY REPO IDENTITY — see `_isOwnWorkingCopy`): probe `'full'`.
451
+ * Sibling worktrees and host-registry entries are real peers here, so the
452
+ * full findPeers union runs — and self-exclusion becomes load-bearing (see
453
+ * below). Containment alone is explicitly NOT sufficient: `listWorktrees`
454
+ * runs `git worktree list` in `process.cwd()`, so the full probe can only
455
+ * measure the working copy this PROCESS is in; picking it for an ancestor
456
+ * or descendant directory that is a DIFFERENT repository measures the wrong
457
+ * repo and returns its emptiness as the target's verdict.
458
+ *
459
+ * - `repoRoot` is a FOREIGN repo (the actual #908 case): probe `'lock-only'`.
460
+ * Two synchronous calls — `readLock` + `isLockLive` — with no `git worktree
461
+ * list`, no 2s race, no registry read. Self-exclusion is trivially
462
+ * satisfied (my session is not in that repo's lock). This mirrors
463
+ * `scripts/lib/dispatcher/enumerate.mjs :: resolveCandidate`, which makes
464
+ * exactly this trade for exactly this reason.
465
+ *
466
+ * There is deliberately NO probe override: an API in which the caller can pick
467
+ * the wrong variant is an API in which the caller eventually will (this failure
468
+ * mode was observed live — see the id-space note below). `opts.cwd` exists as a
469
+ * test seam for the auto-detection input, not as a way to force a probe.
470
+ *
471
+ * KNOWN LIMIT (accepted, cost-driven): the `'lock-only'` probe reads only that
472
+ * repo root's own lock. A foreign session running exclusively in a SIBLING
473
+ * worktree of a foreign repo is not seen. Callers who need that coverage must
474
+ * call `findPeers(<that repo>)` directly and pay the git cost.
475
+ *
476
+ * ── Self-exclusion is derived, never trusted (Falle 1) ──────────────────────
477
+ *
478
+ * `findPeers`'s `opts.mySessionId` self-excludes on Surface A+B ONLY when it is
479
+ * the UUID from `session.lock` — a semantic id (`<branch>-<date>-<mode>-<n>`)
480
+ * silently fails to exclude there, and the caller's own heartbeat comes back as
481
+ * a "peer" of itself. That mistake was made live by a caller reading the very
482
+ * JSDoc that documents it. So this function does not accept the id it needs:
483
+ * it READS it, from `readLock({ repoRoot }).session_id` (the UUID) plus
484
+ * `.semantic_session_id`, and post-filters BOTH id-spaces out of the result —
485
+ * which also covers the id-space mismatch on Surface C (STATE.md `session:`)
486
+ * and any registry entry a non-Claude harness wrote under a semantic id.
487
+ * `opts.mySessionId` is accepted and IGNORED (see its param doc).
488
+ *
489
+ * ── Liveness is heartbeat-based, never PID-based (Falle 2) ──────────────────
490
+ *
491
+ * The canonical test is `isLockLive(lock, nowMs)` (heartbeat vs `ttl_hours`,
492
+ * falling back to `started_at`). `process.kill(pid, 0)` / `isPidAliveOnHost`
493
+ * are deliberately NOT in this decision tree: the recorded pid is the ephemeral
494
+ * SessionStart-HOOK pid, not the session's, so a dead pid says nothing about
495
+ * the session. A lock with a FRESH heartbeat and a DEAD pid therefore counts as
496
+ * LIVE — that is the contract, not a bug (session-discovery.mjs module header;
497
+ * #799 evaluated 2026-07-17 → explicit NO-GO, "do not re-attempt without new
498
+ * evidence"). `tests/lib/peer-discovery.test.mjs` I3 pins it.
499
+ *
500
+ * ── Only `source: 'discovered'` counts (Falle 3) ────────────────────────────
501
+ *
502
+ * `findPeers` intentionally does NOT dedupe across sources, so one peer can
503
+ * appear twice (once `'discovered'`, once `'state-md'`) — a raw `peers.length`
504
+ * is double-counting AND over-triggering. Only `'discovered'` (live lock /
505
+ * fresh registry heartbeat) is evidence of a RUNNING session; `'state-md'` is a
506
+ * committed artifact that outlives its session (a crashed session leaves it
507
+ * behind), so it is excluded from the liveness decision. Same filter as
508
+ * `skills/_shared/parallel-aware-preamble.md` (`p.source !== 'state-md'`).
509
+ * Within `'discovered'`, `discoverActiveSessions` has already deduped by
510
+ * sessionId, so `peerCount` needs no further dedupe.
511
+ *
512
+ * ── Fail-safe direction: UNKNOWN ⇒ live: true ───────────────────────────────
513
+ *
514
+ * The two errors are not symmetric. A false `true` costs one redundant
515
+ * re-measurement. A false `false` reprints 9-hour-old numbers as current fact —
516
+ * the exact #908 incident. So the invariant is: **`live: false` is returned
517
+ * ONLY after a measurement that succeeded and found nothing.** Anything that
518
+ * prevents measurement — unusable `repoRoot` (`'invalid-repo-root'`), path gone
519
+ * (`'repo-root-missing'`), lock present but unparseable (`'lock-unreadable'`),
520
+ * or any unexpected throw (`'probe-error'`) — yields `live: true`.
521
+ * A repo with NO lock is not "unknown": it is a successful measurement with a
522
+ * negative result (`'no-lock'`, `live: false`).
523
+ *
524
+ * ── The full path needs its own liveness proof (Falle 4) ────────────────────
525
+ *
526
+ * `findPeers` fails open PER SURFACE and returns only `{ peers }` — there is no
527
+ * degraded/error channel — so "both surfaces broke" and "measured, nobody home"
528
+ * arrive as the same value. Left alone, the full path would answer `no-peers` →
529
+ * `live: false` after measuring nothing, i.e. exactly the invariant above
530
+ * inverted on the branch that runs most often.
531
+ *
532
+ * Decision (#908 F3): do NOT plumb a degraded flag through findPeers. The
533
+ * fail-open behaviour that hides the failure lives one and two layers further
534
+ * down (`discoverActiveSessions` catches and falls back; `listWorktrees`
535
+ * returns `[]` on git failure), so a flag on findPeers would only ever report
536
+ * failures injected through a test seam — green tests, zero production effect.
537
+ * Instead the verdict is validated against a signal this function already
538
+ * holds: **when our own lock is live, the discovered surface MUST have returned
539
+ * our own entry.** `discoverActiveSessions` reads this worktree's lock either
540
+ * via `git worktree list` or, when that throws/times out, via its A1
541
+ * single-worktree fallback. Zero discovered entries under a live own lock is
542
+ * therefore not a quiet repo — it is a surface that produced nothing at all →
543
+ * `live: true`, `reason: 'probe-degraded'`, `probe: 'full-degraded'`. The
544
+ * distinct probe value names the difference rather than hiding it inside
545
+ * `'full'`. This is also why `findPeers` is called with `mySessionId: null`:
546
+ * self-exclusion is done by the post-filter (which covers both id-spaces
547
+ * anyway), and excluding our own entry upstream would erase the canary.
548
+ *
549
+ * RESIDUAL GAP — CLOSED (#919.3): the canary needs a live own lock to assert
550
+ * against. When `repoRoot` is my own working copy and NO live lock exists
551
+ * there, a total surface failure used to be indistinguishable from a quiet
552
+ * repo and yielded `live: false`. The liveness contract that closing it
553
+ * required now exists: `listWorktreesChecked()` (worktree/listing.mjs) reports
554
+ * whether `git worktree list` actually RAN (`ok: false` = the git invocation
555
+ * failed — previously swallowed into `[]`). So when the discovered surface
556
+ * produced NOTHING and no live own lock can vouch for it, the full path runs
557
+ * that check directly (raced against a 2s timeout, mirroring
558
+ * session-discovery's own race): git demonstrably ran → `no-peers` stands as a
559
+ * genuine measurement; git failed or hung → `live: true`,
560
+ * `reason: 'probe-degraded'`, `probe: 'full-degraded'` — the same fail-safe
561
+ * direction as every other unmeasurable state. Tests reach this confirmation
562
+ * through the SAME `opts.listWorktreesImpl` seam findPeers forwards: a seam
563
+ * that resolves proves the surface functional, a throwing/rejecting seam
564
+ * reproduces the git failure.
565
+ *
566
+ * NEVER THROWS — like every other function in this module. All paths return a
567
+ * verdict; the outermost catch maps the impossible case to `'probe-error'`.
568
+ *
569
+ * @param {string} repoRoot Absolute path to the repository root to probe.
570
+ * @param {object} [opts]
571
+ * @param {string} [opts.mySessionId] ACCEPTED AND IGNORED. Kept so the many
572
+ * existing `{ mySessionId }` call sites can be pointed at this function
573
+ * verbatim, and so passing the WRONG id-space (the observed live failure) is
574
+ * inert instead of silently wrong. Self-exclusion is derived from the lock —
575
+ * an id supplied by the caller is unverifiable, and trusting it could only
576
+ * ever REMOVE peers, i.e. push the verdict to the unsafe side.
577
+ * @param {string} [opts.cwd] Test seam for the own-vs-foreign auto-detection
578
+ * (defaults to `process.cwd()`). Not a probe override.
579
+ * @param {number} [opts.now] ms-since-epoch (test seam for heartbeat/age).
580
+ * @param {number} [opts.freshnessMin] Forwarded to findPeers (full probe only).
581
+ * @param {number} [opts.maxAgeHours] Forwarded to findPeers (full probe only).
582
+ * @param {Function} [opts.listWorktreesImpl] DI seam, forwarded to findPeers.
583
+ * @param {Function} [opts.registryReader] DI seam, forwarded to findPeers.
584
+ * @param {Function} [opts.findPeersImpl] DI seam replacing findPeers (tests).
585
+ * @returns {Promise<{
586
+ * live: boolean,
587
+ * reason: 'live-peer-lock'|'live-peer-discovered'|'no-lock'|'lock-expired'|'no-peers'
588
+ * |'invalid-repo-root'|'repo-root-missing'|'lock-unreadable'|'probe-degraded'
589
+ * |'probe-error',
590
+ * probe: 'none'|'lock-only'|'full'|'full-degraded',
591
+ * peerCount: number,
592
+ * peer: object|null
593
+ * }>} `peer` is a representative live peer (the first) or null; `peerCount`
594
+ * carries the total, so a consumer can phrase "live foreign session, 2h old"
595
+ * without measuring a second time.
596
+ */
597
+ export async function checkLiveForeignSession(repoRoot, opts = {}) {
598
+ const nowMs = typeof opts.now === 'number' ? opts.now : Date.now();
599
+
600
+ try {
601
+ // -- Pre-checks: anything that prevents measurement fails SAFE (live:true).
602
+ if (typeof repoRoot !== 'string' || repoRoot.trim() === '') {
603
+ return _verdict(true, 'invalid-repo-root', PROBE_NONE);
604
+ }
605
+
606
+ const rootAbs = _realpathOrResolved(repoRoot);
607
+ if (!fs.existsSync(rootAbs)) {
608
+ return _verdict(true, 'repo-root-missing', PROBE_NONE);
609
+ }
610
+
611
+ // readLock() collapses "absent" and "unparseable" into null. They are
612
+ // opposite verdicts here (nobody-home vs cannot-tell), so split them: a
613
+ // lock file that exists but does not parse means SOMETHING wrote it and we
614
+ // cannot say whether that session is alive → fail safe.
615
+ const lock = readLock({ repoRoot: rootAbs });
616
+ if (lock === null && fs.existsSync(path.join(rootAbs, LOCK_PATH))) {
617
+ return _verdict(true, 'lock-unreadable', PROBE_NONE);
618
+ }
619
+
620
+ const cwdAbs = _realpathOrResolved(
621
+ typeof opts.cwd === 'string' && opts.cwd.trim() !== '' ? opts.cwd : process.cwd(),
622
+ );
623
+
624
+ // ------------------------------------------------------------------
625
+ // Cheap probe — foreign repo. Two sync calls, no git, no registry.
626
+ // ------------------------------------------------------------------
627
+ if (!_isOwnWorkingCopy(rootAbs, cwdAbs)) {
628
+ if (lock === null) {
629
+ return _verdict(false, 'no-lock', PROBE_LOCK_ONLY);
630
+ }
631
+ if (!isLockLive(lock, nowMs)) {
632
+ // Heartbeat older than ttl_hours — dead lease, not a live session.
633
+ return _verdict(false, 'lock-expired', PROBE_LOCK_ONLY);
634
+ }
635
+ // Fresh heartbeat ⇒ live, whatever the recorded pid says (#799).
636
+ return _verdict(true, 'live-peer-lock', PROBE_LOCK_ONLY, {
637
+ peerCount: 1,
638
+ peer: _peerFromLockBody(lock, rootAbs, nowMs),
639
+ });
640
+ }
641
+
642
+ // ------------------------------------------------------------------
643
+ // Full probe — my own working copy: sibling worktrees + registry count.
644
+ // ------------------------------------------------------------------
645
+
646
+ // Self-ids are READ, never accepted from the caller (see JSDoc, Falle 1).
647
+ // `mySessionId: null` is passed DELIBERATELY: self-exclusion happens
648
+ // entirely in the post-filter below (it covers both id-spaces, which
649
+ // findPeers's single-id seam cannot), and suppressing our own entry INSIDE
650
+ // findPeers would also destroy the canary — our own entry is the only
651
+ // production-observable proof that the discovered surface functioned.
652
+ const selfIds = new Set();
653
+ if (lock && typeof lock.session_id === 'string') selfIds.add(lock.session_id);
654
+ if (lock && typeof lock.semantic_session_id === 'string' && lock.semantic_session_id !== '') {
655
+ selfIds.add(lock.semantic_session_id);
656
+ }
657
+
658
+ const findPeersFn = typeof opts.findPeersImpl === 'function' ? opts.findPeersImpl : findPeers;
659
+ const result = await findPeersFn(rootAbs, {
660
+ mySessionId: null,
661
+ now: nowMs,
662
+ freshnessMin: opts.freshnessMin,
663
+ maxAgeHours: opts.maxAgeHours,
664
+ listWorktreesImpl: opts.listWorktreesImpl,
665
+ registryReader: opts.registryReader,
666
+ });
667
+
668
+ const allPeers = Array.isArray(result?.peers) ? result.peers : [];
669
+ // Falle 3: filter by source — a raw count double-counts (no cross-source
670
+ // dedup upstream, by design) and would treat a leftover STATE.md as a
671
+ // running session. No extra sessionId dedupe: within 'discovered',
672
+ // discoverActiveSessions already deduped.
673
+ const discoveredAll = allPeers.filter((p) => (
674
+ p && p.source === SOURCE_DISCOVERED && typeof p.sessionId === 'string'
675
+ ));
676
+ const livePeers = discoveredAll.filter((p) => !selfIds.has(p.sessionId));
677
+
678
+ if (livePeers.length === 0) {
679
+ // Falle 4 — the full path's negative verdict needs a liveness proof of
680
+ // its own. findPeers fails open per surface and returns ONLY `{ peers }`,
681
+ // so "both surfaces broke" and "measured, nobody home" are the same
682
+ // value. Canary: when our own lock is live, the discovered surface MUST
683
+ // have returned our own entry — `discoverActiveSessions` reads this
684
+ // worktree's lock either through `git worktree list` or, on failure, the
685
+ // A1 single-worktree fallback. Zero discovered entries under a live own
686
+ // lock therefore means the surface produced nothing at all, not that the
687
+ // repo is quiet → fail safe.
688
+ if (discoveredAll.length === 0 && lock !== null && isLockLive(lock, nowMs)) {
689
+ return _verdict(true, 'probe-degraded', PROBE_FULL_DEGRADED);
690
+ }
691
+ // #919.3 — the canary above needs a live own lock to assert against.
692
+ // Without one, "total surface failure" and "quiet repo" still arrive as
693
+ // the same empty list (the documented residual gap). Close it with a
694
+ // direct liveness check of the git surface itself: did `git worktree
695
+ // list` actually RUN? Only reached when the discovered surface produced
696
+ // NOTHING — any discovered entry is already proof the surface
697
+ // functioned, so the extra git spawn is paid solely on the branch that
698
+ // needs it.
699
+ if (discoveredAll.length === 0) {
700
+ const gitRan = await _confirmWorktreeListRan(opts);
701
+ if (!gitRan) {
702
+ return _verdict(true, 'probe-degraded', PROBE_FULL_DEGRADED);
703
+ }
704
+ }
705
+ return _verdict(false, 'no-peers', PROBE_FULL);
706
+ }
707
+ return _verdict(true, 'live-peer-discovered', PROBE_FULL, {
708
+ peerCount: livePeers.length,
709
+ peer: _peerFromFindPeersEntry(livePeers[0]),
710
+ });
711
+ } catch {
712
+ // Unreachable by contract (every callee is documented never-throws), but a
713
+ // regression here must not turn a probe into an exception at the call site.
714
+ return _verdict(true, 'probe-error', PROBE_NONE);
715
+ }
716
+ }
717
+
718
+ // ===========================================================================
719
+ // CLI entry (#908 Befund 3) — `checkLiveForeignSession` from a shell.
720
+ //
721
+ // The consumer of this function is the Fact-Staleness Annotation rule in
722
+ // `skills/wave-executor/wave-loop.md` (Trigger 3), which a COORDINATOR LLM
723
+ // evaluates once per wave. A coordinator has Bash, not an ESM module loader —
724
+ // documenting the call as an `import` made the trigger unevaluatable, so the
725
+ // rule shipped inert. This entry point is what makes it executable.
726
+ //
727
+ // Contract follows `.claude/rules/cli-design.md` and the in-repo precedent of
728
+ // `scripts/lib/fetch-baseline.mjs` (a lib module with a documented CLI invoked
729
+ // from a skill body): data → stdout, diagnostics → stderr, `--json` for
730
+ // machine-readable output, documented exit codes.
731
+ //
732
+ // NOTE on exit codes: a live peer is a RESULT, not an error. `live` is read
733
+ // from the payload; the exit code reports whether the probe RAN. Encoding the
734
+ // verdict in the exit code would collide with the usage-error code and would
735
+ // invert the fail-safe direction on any shell that treats non-zero as failure.
736
+ // ===========================================================================
737
+
738
+ const CLI_USAGE = `Usage: node scripts/lib/peer-discovery.mjs --check-live <repoRoot> [--cwd <dir>] [--json]
739
+
740
+ Probe whether a LIVE FOREIGN session is running in <repoRoot>.
741
+
742
+ Options:
743
+ --check-live <repoRoot> Repository root to probe (required).
744
+ --cwd <dir> Input for own-vs-foreign probe selection (default: process.cwd()).
745
+ --json Emit the verdict as JSON on stdout.
746
+ -h, --help Print this help.
747
+
748
+ Output (stdout) — JSON shape with --json:
749
+ { "live": bool, "reason": string, "probe": string, "peerCount": number, "peer": object|null }
750
+
751
+ Exit codes:
752
+ 0 the probe ran; a verdict was produced (read "live" from the payload —
753
+ a live foreign session is a RESULT, not an error)
754
+ 1 usage error (missing or unknown argument)
755
+ 2 system error (the probe threw, which its contract forbids)
756
+ `;
757
+
758
+ /**
759
+ * One-line human rendering of a verdict. Key=value so it stays greppable
760
+ * without being mistaken for the machine format (`--json`).
761
+ *
762
+ * @param {{live:boolean,reason:string,probe:string,peerCount:number,peer:object|null}} v
763
+ * @returns {string}
764
+ */
765
+ function _formatVerdictHuman(v) {
766
+ const parts = [
767
+ `live=${v.live}`,
768
+ `reason=${v.reason}`,
769
+ `probe=${v.probe}`,
770
+ `peerCount=${v.peerCount}`,
771
+ ];
772
+ if (v.peer && v.peer.sessionId) parts.push(`peer=${v.peer.sessionId}`);
773
+ return parts.join(' ');
774
+ }
775
+
776
+ /**
777
+ * CLI main. Never throws; sets `process.exitCode` and returns so a pending
778
+ * stdout write can drain naturally (an explicit `process.exit()` races the
779
+ * kernel pipe buffer — see the same note in `scripts/lib/description-surface.mjs`).
780
+ *
781
+ * @param {string[]} argv Arguments after `node <file>`.
782
+ * @returns {Promise<void>}
783
+ */
784
+ async function _cliMain(argv) {
785
+ let parsed;
786
+ try {
787
+ parsed = parseArgs({
788
+ args: argv,
789
+ options: {
790
+ 'check-live': { type: 'string' },
791
+ cwd: { type: 'string' },
792
+ json: { type: 'boolean', default: false },
793
+ help: { type: 'boolean', short: 'h', default: false },
794
+ },
795
+ allowPositionals: false,
796
+ strict: true,
797
+ });
798
+ } catch (err) {
799
+ process.stderr.write(`peer-discovery: ${err.message}\n\n${CLI_USAGE}`);
800
+ process.exitCode = 1;
801
+ return;
802
+ }
803
+
804
+ if (parsed.values.help) {
805
+ process.stdout.write(CLI_USAGE);
806
+ return;
807
+ }
808
+
809
+ const target = parsed.values['check-live'];
810
+ if (typeof target !== 'string' || target.trim() === '') {
811
+ process.stderr.write(`peer-discovery: --check-live <repoRoot> is required\n\n${CLI_USAGE}`);
812
+ process.exitCode = 1;
813
+ return;
814
+ }
815
+
816
+ let verdict;
817
+ try {
818
+ verdict = await checkLiveForeignSession(target, {
819
+ cwd: typeof parsed.values.cwd === 'string' ? parsed.values.cwd : undefined,
820
+ });
821
+ } catch (err) {
822
+ // Unreachable by contract — kept so a regression surfaces as exit 2 with a
823
+ // one-line diagnostic instead of an unhandled rejection stack trace.
824
+ process.stderr.write(`peer-discovery: probe failed unexpectedly: ${err.message}\n`);
825
+ process.exitCode = 2;
826
+ return;
827
+ }
828
+
829
+ process.stdout.write(
830
+ (parsed.values.json ? JSON.stringify(verdict) : _formatVerdictHuman(verdict)) + '\n',
831
+ );
832
+ }
833
+
834
+ const __filename = fileURLToPath(import.meta.url);
835
+ const _isCliMain =
836
+ typeof process !== 'undefined'
837
+ && process.argv[1] !== null
838
+ && process.argv[1] !== undefined
839
+ && path.resolve(process.argv[1]) === path.resolve(__filename);
840
+
841
+ // Called WITHOUT `await` on purpose: a top-level await would make this module
842
+ // async for every importer, and `_cliMain` cannot reject (all paths are caught
843
+ // internally). Node drains the event loop before exiting, so `process.exitCode`
844
+ // set inside still takes effect. Same shape as scripts/lib/description-surface.mjs.
845
+ if (_isCliMain) _cliMain(process.argv.slice(2));