@cohortapp/agent-sdk 2.11.15 → 2.13.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 (280) hide show
  1. package/.env.example +37 -22
  2. package/README.md +2 -0
  3. package/bin/maestro.mjs +117 -39
  4. package/bin/maestro.test.mjs +175 -5
  5. package/docs/guides/front-door-session.md +313 -0
  6. package/docs/guides/mac-mini.md +100 -28
  7. package/docs/guides/org-onboarding.md +1 -1
  8. package/docs/guides/setup-wizard.md +9 -5
  9. package/docs/runbooks/cohort-cutover.md +11 -1
  10. package/docs/runbooks/mac-mini-bootstrap.md +38 -63
  11. package/lib/cadence-bus-requeue.test.mjs +83 -0
  12. package/lib/cadence-bus.mjs +43 -7
  13. package/lib/channels/inbox-item.mjs +59 -2
  14. package/lib/cli/board.mjs +285 -0
  15. package/lib/cli/board.test.mjs +227 -0
  16. package/lib/cli/design.mjs +185 -0
  17. package/lib/cli/design.test.mjs +270 -0
  18. package/lib/cli/doctor-checks.mjs +441 -0
  19. package/lib/cli/doctor-checks.test.mjs +336 -0
  20. package/lib/cli/global-setup-extras.mjs +454 -0
  21. package/lib/cli/global-setup-extras.test.mjs +462 -0
  22. package/lib/cli/inbox.mjs +304 -0
  23. package/lib/cli/inbox.test.mjs +230 -0
  24. package/lib/cli/session-ack.mjs +63 -0
  25. package/lib/cli/session-ack.test.mjs +63 -0
  26. package/lib/cli/session.mjs +760 -0
  27. package/lib/cli/session.test.mjs +613 -0
  28. package/lib/collective/global-config.mjs +209 -6
  29. package/lib/collective/global-config.test.mjs +145 -0
  30. package/lib/collective/global-skills.mjs +145 -0
  31. package/lib/collective/global-skills.test.mjs +126 -0
  32. package/lib/collective/presence.mjs +4 -3
  33. package/lib/collective/vendor-skills.mjs +305 -0
  34. package/lib/collective/vendor-skills.test.mjs +306 -0
  35. package/lib/comms/send-gate.mjs +115 -0
  36. package/lib/comms/send-gate.test.mjs +113 -0
  37. package/lib/design/design-md.mjs +793 -0
  38. package/lib/design/design-md.test.mjs +318 -0
  39. package/lib/design/fixtures/DESIGN.golden.md +238 -0
  40. package/lib/design/fixtures/PRODUCT.golden.md +67 -0
  41. package/lib/design/fixtures/foundation.json +133 -0
  42. package/lib/design/refresh-gate.mjs +154 -0
  43. package/lib/design/refresh-gate.test.mjs +144 -0
  44. package/lib/design/write.mjs +275 -0
  45. package/lib/design/write.test.mjs +241 -0
  46. package/lib/feature-init.mjs +2 -2
  47. package/lib/mcp/server.test.mjs +9 -4
  48. package/lib/model-router/spawn.test.mjs +21 -0
  49. package/lib/org/board-mine-cache.mjs +99 -0
  50. package/lib/org/board-mine-cache.test.mjs +53 -0
  51. package/lib/org/board.mjs +11 -0
  52. package/lib/org/board.test.mjs +11 -1
  53. package/lib/org/client.mjs +36 -0
  54. package/lib/org/client.test.mjs +46 -0
  55. package/lib/org/inbound/directedness.mjs +18 -2
  56. package/lib/org/inbound/directedness.test.mjs +58 -0
  57. package/lib/org/inbound/index.mjs +8 -1
  58. package/lib/org/inbound/index.test.mjs +22 -0
  59. package/lib/org/mesh-directives.test.mjs +110 -0
  60. package/lib/org/mesh.mjs +61 -1
  61. package/lib/org/protocol.checksum +1 -1
  62. package/lib/org/protocol.mjs +52 -0
  63. package/lib/org/protocol.test.mjs +12 -1
  64. package/lib/org/registry.mjs +3 -2
  65. package/lib/org/tool-surface.mjs +120 -0
  66. package/lib/org/tool-surface.test.mjs +118 -5
  67. package/lib/prompts/parallelism.mjs +79 -0
  68. package/lib/prompts/parallelism.test.mjs +177 -0
  69. package/lib/security/external-content.mjs +1 -1
  70. package/lib/security/external-content.test.mjs +17 -0
  71. package/lib/session/config.mjs +137 -0
  72. package/lib/session/config.test.mjs +92 -0
  73. package/lib/session/feed-core.mjs +229 -0
  74. package/lib/session/feed-core.test.mjs +198 -0
  75. package/lib/session/first-run.mjs +126 -0
  76. package/lib/session/first-run.test.mjs +121 -0
  77. package/lib/session/frontdoor.mjs +266 -0
  78. package/lib/session/frontdoor.test.mjs +205 -0
  79. package/lib/session/handoffs.mjs +295 -0
  80. package/lib/session/handoffs.test.mjs +183 -0
  81. package/lib/session/identity.mjs +220 -0
  82. package/lib/session/identity.test.mjs +180 -0
  83. package/lib/session/inbox-claims.mjs +434 -0
  84. package/lib/session/inbox-claims.test.mjs +286 -0
  85. package/lib/session/launch-args.mjs +161 -0
  86. package/lib/session/launch-args.test.mjs +157 -0
  87. package/lib/session/liveness.mjs +174 -0
  88. package/lib/session/liveness.test.mjs +100 -0
  89. package/lib/session/status-summary.mjs +172 -0
  90. package/lib/session/status-summary.test.mjs +118 -0
  91. package/lib/session-permissions.mjs +39 -3
  92. package/lib/session-permissions.test.mjs +20 -0
  93. package/lib/setup/claude-probe.mjs +161 -24
  94. package/lib/setup/claude-probe.test.mjs +187 -0
  95. package/lib/setup/sections/learning.mjs +2 -1
  96. package/lib/setup/sections/model.mjs +104 -24
  97. package/lib/setup/sections/model.test.mjs +240 -0
  98. package/lib/setup/sections/org.mjs +27 -2
  99. package/lib/setup/sections/org.test.mjs +35 -2
  100. package/lib/setup/sections/verify.mjs +5 -0
  101. package/lib/setup/state.mjs +30 -10
  102. package/lib/setup/state.test.mjs +24 -1
  103. package/lib/singleton.js +11 -3
  104. package/lib/singleton.test.mjs +16 -0
  105. package/lib/subagents/lock.mjs +1 -1
  106. package/lib/telemetry/collect.mjs +270 -6
  107. package/lib/telemetry/collect.test.mjs +196 -1
  108. package/lib/upgrade/global-refresh.mjs +108 -0
  109. package/lib/upgrade/global-refresh.test.mjs +65 -0
  110. package/lib/upgrade/launchd-reconcile.mjs +327 -0
  111. package/lib/upgrade/launchd-reconcile.test.mjs +272 -0
  112. package/lib/upgrade/post-steps.mjs +151 -0
  113. package/lib/upgrade/post-steps.test.mjs +200 -0
  114. package/lib/upgrade/verify.mjs +215 -0
  115. package/lib/upgrade/verify.test.mjs +164 -0
  116. package/lib/voice/outbound.mjs +3 -2
  117. package/lib/voice/post-call-brief.mjs +2 -1
  118. package/lib/voice/session-rotation.mjs +6 -1
  119. package/lib/voice/session-rotation.test.mjs +114 -0
  120. package/package.json +3 -3
  121. package/plugins/maestro-skills/plugin.json +25 -1
  122. package/plugins/maestro-skills/skills/board-work.md +63 -0
  123. package/plugins/maestro-skills/skills/cohort-design.md +153 -0
  124. package/plugins/maestro-skills/skills/inbound-triage.md +80 -0
  125. package/plugins/maestro-skills/skills/main-session.md +102 -0
  126. package/plugins/maestro-skills/skills/peer-sessions.md +65 -0
  127. package/plugins/maestro-skills/skills/persona-discipline.md +75 -0
  128. package/plugins/maestro-skills/vendor/emilkowalski/LICENSE +21 -0
  129. package/plugins/maestro-skills/vendor/emilkowalski/UPSTREAM.json +70 -0
  130. package/plugins/maestro-skills/vendor/emilkowalski/skills/animate/RECIPES.md +324 -0
  131. package/plugins/maestro-skills/vendor/emilkowalski/skills/animate/SKILL.md +199 -0
  132. package/plugins/maestro-skills/vendor/emilkowalski/skills/animation-vocabulary/SKILL.md +173 -0
  133. package/plugins/maestro-skills/vendor/emilkowalski/skills/apple-design/SKILL.md +282 -0
  134. package/plugins/maestro-skills/vendor/emilkowalski/skills/emil-design-eng/SKILL.md +674 -0
  135. package/plugins/maestro-skills/vendor/emilkowalski/skills/find-animation-opportunities/SKILL.md +132 -0
  136. package/plugins/maestro-skills/vendor/emilkowalski/skills/improve-animations/AUDIT.md +115 -0
  137. package/plugins/maestro-skills/vendor/emilkowalski/skills/improve-animations/PLAN-TEMPLATE.md +73 -0
  138. package/plugins/maestro-skills/vendor/emilkowalski/skills/improve-animations/SKILL.md +101 -0
  139. package/plugins/maestro-skills/vendor/emilkowalski/skills/prototype/PICKER.md +197 -0
  140. package/plugins/maestro-skills/vendor/emilkowalski/skills/prototype/SKILL.md +90 -0
  141. package/plugins/maestro-skills/vendor/emilkowalski/skills/review-animations/SKILL.md +112 -0
  142. package/plugins/maestro-skills/vendor/emilkowalski/skills/review-animations/STANDARDS.md +187 -0
  143. package/plugins/maestro-skills/vendor/impeccable/LICENSE +191 -0
  144. package/plugins/maestro-skills/vendor/impeccable/NOTICE.md +11 -0
  145. package/plugins/maestro-skills/vendor/impeccable/SKILL.md +86 -0
  146. package/plugins/maestro-skills/vendor/impeccable/UPSTREAM.json +201 -0
  147. package/plugins/maestro-skills/vendor/impeccable/agents/impeccable-asset-producer.md +42 -0
  148. package/plugins/maestro-skills/vendor/impeccable/agents/impeccable-documenter.md +29 -0
  149. package/plugins/maestro-skills/vendor/impeccable/agents/impeccable-finish-reviewer.md +43 -0
  150. package/plugins/maestro-skills/vendor/impeccable/agents/impeccable-manual-edit-applier.md +97 -0
  151. package/plugins/maestro-skills/vendor/impeccable/reference/adapt.md +312 -0
  152. package/plugins/maestro-skills/vendor/impeccable/reference/adapt.native.md +58 -0
  153. package/plugins/maestro-skills/vendor/impeccable/reference/android.md +46 -0
  154. package/plugins/maestro-skills/vendor/impeccable/reference/animate.md +89 -0
  155. package/plugins/maestro-skills/vendor/impeccable/reference/audit.md +136 -0
  156. package/plugins/maestro-skills/vendor/impeccable/reference/audit.native.md +139 -0
  157. package/plugins/maestro-skills/vendor/impeccable/reference/bolder.md +33 -0
  158. package/plugins/maestro-skills/vendor/impeccable/reference/clarify.md +94 -0
  159. package/plugins/maestro-skills/vendor/impeccable/reference/colorize.md +86 -0
  160. package/plugins/maestro-skills/vendor/impeccable/reference/craft-floor.md +44 -0
  161. package/plugins/maestro-skills/vendor/impeccable/reference/craft.md +5 -0
  162. package/plugins/maestro-skills/vendor/impeccable/reference/critique.md +806 -0
  163. package/plugins/maestro-skills/vendor/impeccable/reference/degraded/asset-producer.md +37 -0
  164. package/plugins/maestro-skills/vendor/impeccable/reference/degraded/documenter.md +24 -0
  165. package/plugins/maestro-skills/vendor/impeccable/reference/degraded/finish-reviewer.md +38 -0
  166. package/plugins/maestro-skills/vendor/impeccable/reference/degraded/manual-edit-applier.md +92 -0
  167. package/plugins/maestro-skills/vendor/impeccable/reference/delight.md +70 -0
  168. package/plugins/maestro-skills/vendor/impeccable/reference/distill.md +111 -0
  169. package/plugins/maestro-skills/vendor/impeccable/reference/doctor.md +54 -0
  170. package/plugins/maestro-skills/vendor/impeccable/reference/document.md +416 -0
  171. package/plugins/maestro-skills/vendor/impeccable/reference/extract.md +69 -0
  172. package/plugins/maestro-skills/vendor/impeccable/reference/harden.md +336 -0
  173. package/plugins/maestro-skills/vendor/impeccable/reference/hooks.md +111 -0
  174. package/plugins/maestro-skills/vendor/impeccable/reference/init.md +131 -0
  175. package/plugins/maestro-skills/vendor/impeccable/reference/ios.md +51 -0
  176. package/plugins/maestro-skills/vendor/impeccable/reference/layout.md +84 -0
  177. package/plugins/maestro-skills/vendor/impeccable/reference/live-setup.md +104 -0
  178. package/plugins/maestro-skills/vendor/impeccable/reference/live.md +325 -0
  179. package/plugins/maestro-skills/vendor/impeccable/reference/new-work.md +147 -0
  180. package/plugins/maestro-skills/vendor/impeccable/reference/onboard.md +234 -0
  181. package/plugins/maestro-skills/vendor/impeccable/reference/operate.md +61 -0
  182. package/plugins/maestro-skills/vendor/impeccable/reference/optimize.md +258 -0
  183. package/plugins/maestro-skills/vendor/impeccable/reference/overdrive.md +127 -0
  184. package/plugins/maestro-skills/vendor/impeccable/reference/polish.md +105 -0
  185. package/plugins/maestro-skills/vendor/impeccable/reference/quieter.md +99 -0
  186. package/plugins/maestro-skills/vendor/impeccable/reference/routing.md +24 -0
  187. package/plugins/maestro-skills/vendor/impeccable/reference/shape.md +59 -0
  188. package/plugins/maestro-skills/vendor/impeccable/reference/typeset.md +80 -0
  189. package/plugins/maestro-skills/vendor/impeccable/reference/visualize.md +46 -0
  190. package/plugins/maestro-skills/vendor/taste-skill/LICENSE +21 -0
  191. package/plugins/maestro-skills/vendor/taste-skill/UPSTREAM.json +37 -0
  192. package/plugins/maestro-skills/vendor/taste-skill/skills/minimalist-skill/SKILL.md +85 -0
  193. package/plugins/maestro-skills/vendor/taste-skill/skills/redesign-skill/SKILL.md +178 -0
  194. package/plugins/maestro-skills/vendor/taste-skill/skills/soft-skill/SKILL.md +98 -0
  195. package/plugins/maestro-skills/vendor/taste-skill/skills/taste-skill/SKILL.md +1206 -0
  196. package/plugins/maestro-skills/vendor/unlazy/LICENSE +21 -0
  197. package/plugins/maestro-skills/vendor/unlazy/SECURITY.md +72 -0
  198. package/plugins/maestro-skills/vendor/unlazy/SKILL.md +104 -0
  199. package/plugins/maestro-skills/vendor/unlazy/UPSTREAM.json +94 -0
  200. package/plugins/maestro-skills/vendor/unlazy/references/dispatch.md +82 -0
  201. package/plugins/maestro-skills/vendor/unlazy/references/gates.md +149 -0
  202. package/plugins/maestro-skills/vendor/unlazy/references/method.md +49 -0
  203. package/plugins/maestro-skills/vendor/unlazy/references/orchestration.md +107 -0
  204. package/plugins/maestro-skills/vendor/unlazy/references/parallel.md +133 -0
  205. package/plugins/maestro-skills/vendor/unlazy/references/token-economy.md +48 -0
  206. package/plugins/maestro-skills/vendor/unlazy/scripts/dispatch-check.mjs +139 -0
  207. package/plugins/maestro-skills/vendor/unlazy/scripts/gate-check.mjs +960 -0
  208. package/plugins/maestro-skills/vendor/unlazy/scripts/gate-lint.mjs +245 -0
  209. package/plugins/maestro-skills/vendor/unlazy/scripts/lib/check-supervisor.mjs +46 -0
  210. package/plugins/maestro-skills/vendor/unlazy/scripts/lib/dispatch.mjs +293 -0
  211. package/plugins/maestro-skills/vendor/unlazy/scripts/lib/gates.mjs +953 -0
  212. package/plugins/maestro-skills/vendor/unlazy/scripts/lib/process-tree.mjs +161 -0
  213. package/plugins/maestro-skills/vendor/unlazy/scripts/lib/regex-worker.mjs +9 -0
  214. package/plugins/maestro-skills/vendor/unlazy/templates/PLAN.md +116 -0
  215. package/plugins/maestro-skills/vendor/unlazy/templates/gates-leaf.md +51 -0
  216. package/plugins/maestro-skills/vendor/unlazy/templates/gates-node.md +51 -0
  217. package/scaffold/CLAUDE.md +24 -0
  218. package/scripts/ci/check-durable-write-seam.mjs +147 -0
  219. package/scripts/ci/check-durable-write-seam.test.mjs +90 -0
  220. package/scripts/ci/check-skill-packs.mjs +388 -0
  221. package/scripts/ci/check-skill-packs.test.mjs +495 -0
  222. package/scripts/ci/check.mjs +6 -0
  223. package/scripts/collective/hook-runner.mjs +39 -4
  224. package/scripts/collective/hook-runner.test.mjs +85 -2
  225. package/scripts/daemon/agent-daemon-board-mine.test.mjs +96 -0
  226. package/scripts/daemon/agent-daemon-design.test.mjs +238 -0
  227. package/scripts/daemon/agent-daemon-frontdoor.test.mjs +60 -0
  228. package/scripts/daemon/agent-daemon.mjs +249 -10
  229. package/scripts/daemon/agent-daemon.test.mjs +73 -0
  230. package/scripts/daemon/assurance-e2e.test.mjs +141 -6
  231. package/scripts/daemon/assurance.mjs +461 -37
  232. package/scripts/daemon/assurance.test.mjs +408 -43
  233. package/scripts/daemon/cadence-consumer-frontdoor.test.mjs +393 -0
  234. package/scripts/daemon/cadence-consumer.mjs +289 -89
  235. package/scripts/daemon/cadence-handlers.mjs +53 -0
  236. package/scripts/daemon/classifier.mjs +1 -1
  237. package/scripts/daemon/dispatcher-resume.test.mjs +166 -0
  238. package/scripts/daemon/dispatcher.mjs +127 -19
  239. package/scripts/daemon/health.mjs +12 -1
  240. package/scripts/daemon/inbox-deferral-session.test.mjs +49 -0
  241. package/scripts/daemon/inbox-deferral.mjs +6 -0
  242. package/scripts/daemon/lib/self-echo.mjs +201 -0
  243. package/scripts/daemon/lib/self-echo.test.mjs +153 -0
  244. package/scripts/daemon/maestro-daemon.mjs +3 -0
  245. package/scripts/daemon/prompt-builder.mjs +19 -3
  246. package/scripts/daemon/responder.mjs +51 -40
  247. package/scripts/daemon/sdk-version.mjs +51 -0
  248. package/scripts/daemon/sdk-version.test.mjs +31 -0
  249. package/scripts/hooks/pre-send-audit.sh +97 -4
  250. package/scripts/hooks/pre-send-audit.test.mjs +140 -1
  251. package/scripts/local-triggers/autoupdate.sh +243 -19
  252. package/scripts/local-triggers/autoupdate.test.mjs +518 -0
  253. package/scripts/local-triggers/generate-plists.sh +24 -1
  254. package/scripts/local-triggers/generate-plists.test.mjs +49 -11
  255. package/scripts/org/send-orgmail.first-contact.test.mjs +102 -0
  256. package/scripts/org/send-orgmail.mjs +27 -3
  257. package/scripts/poller/inbox-privilege-injection.test.mjs +167 -0
  258. package/scripts/poller/slack-poller.mjs +13 -1
  259. package/scripts/poller/utils.mjs +46 -1
  260. package/scripts/poller-launchd/install.sh +19 -11
  261. package/scripts/poller-launchd/install.test.mjs +243 -0
  262. package/scripts/poller-launchd/launchd-poller-wrapper.sh +92 -0
  263. package/scripts/poller-launchd/migrate.sh +66 -0
  264. package/scripts/poller-launchd/poller.plist.template +4 -2
  265. package/scripts/session/feed.mjs +237 -0
  266. package/scripts/session/feed.test.mjs +196 -0
  267. package/scripts/session/supervisor-sh.test.mjs +218 -0
  268. package/scripts/session/supervisor.mjs +328 -0
  269. package/scripts/session/supervisor.sh +141 -0
  270. package/scripts/session/supervisor.test.mjs +482 -0
  271. package/scripts/setup/configure-macos.sh +250 -55
  272. package/scripts/setup/configure-macos.test.mjs +306 -0
  273. package/scripts/setup/init-agent.sh +112 -7
  274. package/scripts/setup/init-agent.test.mjs +220 -1
  275. package/scripts/vendor/skill-packs.mjs +354 -0
  276. package/scripts/vendor/sync-skill-packs.mjs +242 -0
  277. package/scripts/vendor/sync-skill-packs.test.mjs +103 -0
  278. package/scripts/watchdog/memory-watchdog.sh +37 -1
  279. package/scripts/watchdog/memory-watchdog.test.mjs +64 -0
  280. package/scripts/setup/boot-claude-session.sh +0 -94
@@ -0,0 +1,441 @@
1
+ /**
2
+ * lib/cli/doctor-checks.mjs — the auth / org-id / front-door / fleet checks
3
+ * behind `maestro doctor` (design 2026-09-08 §3.9; audit F1, F2, F3, F4).
4
+ *
5
+ * Every decision is a PURE function over values the caller already read —
6
+ * `authMode`, `orgIdShape`, `effectiveSource`, `sdkGlobalInstall`,
7
+ * `tailscaleSsh`, `sessionInstalled` — so each verdict is unit-testable with
8
+ * no machine, no network and no clock. `runDoctorChecks` is the one edge: it
9
+ * reads the files, forks the commands and makes the single org call through
10
+ * injectable `exec` / `fetchImpl` / `listPlists` / `probeClaude` seams, then
11
+ * hands the values to the pure checks. Fail-open at every edge: a command that
12
+ * is missing degrades that one line to `warn`, never a throw.
13
+ *
14
+ * Rows are `{level: "ok"|"warn"|"fail", msg}`; `fail` counts toward doctor's
15
+ * issue total, `warn` is advisory. Secrets never appear in a row — tokens are
16
+ * masked by `mask()` before they reach a message.
17
+ *
18
+ * What replaced what: the doctor block this feeds used to say
19
+ * "ANTHROPIC_API_KEY not set" and count it as an issue on every seat that
20
+ * runs the subscription token — the documented fleet posture. That block is
21
+ * gone; `authMode` is mode-aware.
22
+ *
23
+ * @module lib/cli/doctor-checks
24
+ */
25
+
26
+ "use strict";
27
+
28
+ import { existsSync, readFileSync, readdirSync } from "node:fs";
29
+ import { join, resolve } from "node:path";
30
+ import { homedir } from "node:os";
31
+ import { spawnSync } from "node:child_process";
32
+ import { loadOrgConfig, call } from "../org/client.mjs";
33
+ import { probeClaude as defaultProbeClaude } from "../setup/claude-probe.mjs";
34
+ import { sessionLiveness, SESSION_STALE_MS } from "../telemetry/collect.mjs";
35
+
36
+ export const SDK_PACKAGE = "@cohortapp/agent-sdk";
37
+
38
+ /** Where the tailscale CLI lives, in the order we try it (mirrors telemetry). */
39
+ const TAILSCALE_BINS = Object.freeze([
40
+ "tailscale",
41
+ "/usr/local/bin/tailscale",
42
+ "/opt/homebrew/bin/tailscale",
43
+ "/Applications/Tailscale.app/Contents/MacOS/Tailscale",
44
+ ]);
45
+
46
+ // ---------------------------------------------------------------------------
47
+ // Pure helpers
48
+ // ---------------------------------------------------------------------------
49
+
50
+ /**
51
+ * Parse a .env body into `{KEY: value}`. Quotes stripped, `export ` prefix
52
+ * tolerated, comments/blank lines ignored, EMPTY values dropped (an empty
53
+ * `KEY=` line is "unset" for every consumer we have).
54
+ * @param {string|null|undefined} body
55
+ * @returns {Record<string,string>}
56
+ */
57
+ export function parseDotEnv(body) {
58
+ const out = {};
59
+ for (const raw of String(body || "").split("\n")) {
60
+ const line = raw.trim();
61
+ if (!line || line.startsWith("#")) continue;
62
+ const m = line.match(/^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/);
63
+ if (!m) continue;
64
+ const v = m[2].trim().replace(/^(["'])(.*)\1$/, "$2");
65
+ if (v) out[m[1]] = v;
66
+ }
67
+ return out;
68
+ }
69
+
70
+ /** `nlk_dotenv_secret` → `nlk_…cret`. Short values collapse to `…`. */
71
+ export function mask(v) {
72
+ const s = String(v || "");
73
+ if (!s) return "";
74
+ if (s.length <= 10) return "…";
75
+ return `${s.slice(0, 4)}…${s.slice(-4)}`;
76
+ }
77
+
78
+ /**
79
+ * Which Claude auth path the seat is on, from its (parsed) env.
80
+ * @param {Record<string,string>} env the .env values (merged over the ambient env by the caller)
81
+ * @returns {{mode:"oauth-token"|"subscription"|"api-key"|"none", level:"ok"|"warn"|"fail", msg:string}}
82
+ */
83
+ export function authMode(env = {}) {
84
+ const token = !!env.CLAUDE_CODE_OAUTH_TOKEN;
85
+ const prefer = /^(1|true|yes)$/i.test(String(env.MAESTRO_PREFER_SUBSCRIPTION_AUTH || ""));
86
+ const key = !!env.ANTHROPIC_API_KEY;
87
+ if (token && prefer) return { mode: "oauth-token", level: "ok", msg: "Claude auth: subscription OAuth token (CLAUDE_CODE_OAUTH_TOKEN + MAESTRO_PREFER_SUBSCRIPTION_AUTH=1)" };
88
+ if (token) {
89
+ return {
90
+ mode: "oauth-token", level: "warn",
91
+ msg: "Claude auth: CLAUDE_CODE_OAUTH_TOKEN is set but MAESTRO_PREFER_SUBSCRIPTION_AUTH=1 is not — add it so spawns strip ANTHROPIC_API_KEY and ride the token",
92
+ };
93
+ }
94
+ if (prefer) {
95
+ return {
96
+ mode: "subscription", level: "warn",
97
+ msg: "Claude auth: keychain login (MAESTRO_PREFER_SUBSCRIPTION_AUTH=1, no token) — it expires headlessly; run `claude setup-token` and put the result in .env as CLAUDE_CODE_OAUTH_TOKEN",
98
+ };
99
+ }
100
+ if (key) return { mode: "api-key", level: "ok", msg: "Claude auth: ANTHROPIC_API_KEY (pay-per-token)" };
101
+ return {
102
+ mode: "none", level: "fail",
103
+ msg: "Claude auth: nothing configured — run `claude setup-token` on any logged-in machine and put the result in .env as CLAUDE_CODE_OAUTH_TOKEN (plus MAESTRO_PREFER_SUBSCRIPTION_AUTH=1), or set ANTHROPIC_API_KEY",
104
+ };
105
+ }
106
+
107
+ /**
108
+ * Does this look like an org ID (`org_default_<slug>`) rather than a bare slug?
109
+ * Shared with the setup wizard's org section so both judge the pin the same way.
110
+ */
111
+ export function looksLikeOrgId(v) { return /^org_[a-z0-9]+_[a-z0-9-]+$/i.test(String(v || "")); }
112
+
113
+ /**
114
+ * Is the configured COHORT_ORG_ID the org's ID (what hq compares with strict
115
+ * equality) or its slug (which 401s every call)? `whoamiOrgId`/`orgSlug` come
116
+ * from the server (`org.list` → the active org) when reachable, else "".
117
+ * @param {string} configured
118
+ * @param {string} whoamiOrgId
119
+ * @param {string} orgSlug
120
+ * @returns {{level:"ok"|"warn"|"fail", msg:string}}
121
+ */
122
+ export function orgIdShape(configured, whoamiOrgId, orgSlug) {
123
+ const c = String(configured || "").trim();
124
+ const id = String(whoamiOrgId || "").trim();
125
+ const slug = String(orgSlug || "").trim();
126
+ if (!c) {
127
+ return { level: "warn", msg: `COHORT_ORG_ID is not set${id ? ` — this key belongs to org ${id}` : ""} (the x-org-id pin is optional, but pull-enrollment needs it)` };
128
+ }
129
+ if (id) {
130
+ if (c === id) return { level: "ok", msg: `COHORT_ORG_ID is the org ID (${id}) — matches the server` };
131
+ if (slug && c === slug) {
132
+ return { level: "fail", msg: `COHORT_ORG_ID=${c}: you set the slug — hq compares the pin with strict equality against the org ID, so every call 401s. Set COHORT_ORG_ID=${id}` };
133
+ }
134
+ return { level: "fail", msg: `COHORT_ORG_ID=${c} does not match the org this key belongs to (${id}${slug ? `, slug ${slug}` : ""})` };
135
+ }
136
+ // Server unreachable / not asked: judge the shape alone.
137
+ if (looksLikeOrgId(c)) return { level: "ok", msg: `COHORT_ORG_ID=${c} has the org-ID shape (server not consulted)` };
138
+ return { level: "warn", msg: `COHORT_ORG_ID=${c} looks like a slug — the org ID has the form org_default_<slug>; verify against Cohort → Settings` };
139
+ }
140
+
141
+ /**
142
+ * Where each Cohort value actually comes from, following the precedence the
143
+ * daemon lives by: `config/org.yaml` (the enrollment SoT `maestro setup`
144
+ * writes) beats the ambient env, which beats `.env` (dotenv never overrides
145
+ * an already-set variable). F3 was exactly that org.yaml quietly winning over
146
+ * a `.env` an operator had just edited — so every LOSING value is named too.
147
+ *
148
+ * @param {{orgYaml?:object|null, dotEnv?:Record<string,string>, env?:Record<string,string>}} src
149
+ * @returns {Array<{key:string, source:"org.yaml"|"env"|".env"|"none", value:string, shadowed:string[]}>}
150
+ */
151
+ export function effectiveSource({ orgYaml, dotEnv, env } = {}) {
152
+ const n = (orgYaml && orgYaml.org && orgYaml.org.cohort) || {};
153
+ const de = dotEnv || {};
154
+ const ae = env || {};
155
+ const firstOf = (bag, names) => {
156
+ for (const name of names) if (bag[name]) return { name, value: String(bag[name]) };
157
+ return null;
158
+ };
159
+ const rows = [];
160
+ const push = (key, yamlValue, envNames, opts = {}) => {
161
+ const show = opts.secret ? mask : (v) => String(v);
162
+ const candidates = [];
163
+ if (yamlValue) candidates.push({ source: "org.yaml", label: `org.yaml ${key}`, value: String(yamlValue) });
164
+ const fromEnv = firstOf(ae, envNames);
165
+ if (fromEnv) candidates.push({ source: "env", label: `env ${fromEnv.name}`, value: fromEnv.value });
166
+ const fromDot = firstOf(de, envNames);
167
+ if (fromDot) candidates.push({ source: ".env", label: `.env ${fromDot.name}`, value: fromDot.value });
168
+ if (candidates.length === 0) { rows.push({ key, source: "none", value: "", shadowed: [] }); return; }
169
+ const [winner, ...losers] = candidates;
170
+ rows.push({
171
+ key,
172
+ source: winner.source,
173
+ value: show(winner.value),
174
+ shadowed: losers.filter((l) => l.value !== winner.value).map((l) => `${l.label}=${show(l.value)}`),
175
+ });
176
+ };
177
+ push("base", n.base || n.apiUrl, ["COHORT_BASE", "COHORT_API_URL"]);
178
+ push("orgId", n.orgId, ["COHORT_ORG_ID"]);
179
+ push("token", n.token, ["COHORT_API_TOKEN", "COHORT_TOKEN", "COHORT_API_KEY"], { secret: true });
180
+ push("agentId", n.agentId, ["COHORT_AGENT_ID"]);
181
+ return rows;
182
+ }
183
+
184
+ /** Compare two `x.y.z` strings: -1 / 0 / 1. Non-numeric parts compare as 0. */
185
+ function cmpSemver(a, b) {
186
+ const pa = String(a).split("-")[0].split(".").map((x) => parseInt(x, 10) || 0);
187
+ const pb = String(b).split("-")[0].split(".").map((x) => parseInt(x, 10) || 0);
188
+ for (let i = 0; i < 3; i += 1) {
189
+ const d = (pa[i] || 0) - (pb[i] || 0);
190
+ if (d !== 0) return d < 0 ? -1 : 1;
191
+ }
192
+ return 0;
193
+ }
194
+
195
+ /** Version of SDK_PACKAGE in `npm ls -g` output (JSON or tree text), else "". */
196
+ function parseNpmLs(output) {
197
+ const s = String(output || "").trim();
198
+ if (!s) return "";
199
+ try {
200
+ const j = JSON.parse(s);
201
+ const dep = j && j.dependencies && j.dependencies[SDK_PACKAGE];
202
+ if (dep && typeof dep.version === "string") return dep.version;
203
+ return "";
204
+ } catch { /* text form */ }
205
+ const m = s.match(new RegExp(`${SDK_PACKAGE.replace("/", "\\/")}@(\\d+\\.\\d+\\.\\d+[^\\s]*)`));
206
+ return m ? m[1] : "";
207
+ }
208
+
209
+ /**
210
+ * Is the SDK installed globally, and is it current?
211
+ * @param {string} npmLsOutput `npm ls -g @cohortapp/agent-sdk --json --depth=0` stdout ("" when npm failed)
212
+ * @param {string} npmViewLatest `npm view @cohortapp/agent-sdk version` stdout ("" when offline)
213
+ * @returns {{installed:boolean, version:string, latest:string, level:"ok"|"warn"|"fail", msg:string}}
214
+ */
215
+ export function sdkGlobalInstall(npmLsOutput, npmViewLatest) {
216
+ const version = parseNpmLs(npmLsOutput);
217
+ const latest = String(npmViewLatest || "").trim().split("\n").pop().trim();
218
+ if (!version) {
219
+ // WARN until upgrade() step 4 (WP-M6) refreshes the global install on every
220
+ // seat: today's fleet installs the SDK LOCALLY in the agent dir
221
+ // (autoupdate.sh `npm install … --save`) and is fully functional that way.
222
+ // A `fail` here would turn doctor red on every existing machine the hour
223
+ // 2.12.0 lands — and "doctor is the gate" would then read as an outage.
224
+ return { installed: false, version: "", latest, level: "warn", msg: `${SDK_PACKAGE} is not installed globally — a home-dir session cannot reach \`maestro\`/\`cohort-mcp\` on PATH until it is: npm i -g ${SDK_PACKAGE}@latest (the next \`maestro upgrade\` does this)` };
225
+ }
226
+ if (!latest) return { installed: true, version, latest, level: "ok", msg: `${SDK_PACKAGE} ${version} installed globally (npm latest unknown — offline?)` };
227
+ if (cmpSemver(version, latest) < 0) {
228
+ return { installed: true, version, latest, level: "warn", msg: `${SDK_PACKAGE} ${version} installed globally but npm latest is ${latest} — autoupdate should catch up within the hour, or: npm i -g ${SDK_PACKAGE}@latest` };
229
+ }
230
+ return { installed: true, version, latest, level: "ok", msg: `${SDK_PACKAGE} ${version} installed globally (npm latest ${latest})` };
231
+ }
232
+
233
+ /**
234
+ * Is Tailscale SSH advertised on this machine? `prefsJson` (`tailscale debug
235
+ * prefs`, `RunSSH`) is authoritative; without it, `Self.SSH_HostKeys` in
236
+ * `tailscale status --json` is the next-best evidence. Anything unparseable
237
+ * means Tailscale is absent or down — a warn, since fleet-wide operator access
238
+ * depends on it (F4).
239
+ * @param {object|string} statusJson
240
+ * @param {object|string} [prefsJson]
241
+ * @returns {{level:"ok"|"warn", msg:string, enabled:boolean|null}}
242
+ */
243
+ export function tailscaleSsh(statusJson, prefsJson) {
244
+ const parse = (v) => {
245
+ if (v && typeof v === "object") return v;
246
+ try { return JSON.parse(String(v || "")); } catch { return null; }
247
+ };
248
+ const remedy = "enable with `scripts/setup/configure-macos.sh --tailscale-ssh` (and allow it in the tailnet ACL)";
249
+ const prefs = parse(prefsJson);
250
+ const status = parse(statusJson);
251
+ if (prefs && typeof prefs.RunSSH === "boolean") {
252
+ return prefs.RunSSH
253
+ ? { level: "ok", msg: "Tailscale SSH is on (RunSSH=true)", enabled: true }
254
+ : { level: "warn", msg: `Tailscale SSH is off — operators cannot reach this seat remotely; ${remedy}`, enabled: false };
255
+ }
256
+ if (status && status.Self && typeof status.Self === "object") {
257
+ const keys = status.Self.SSH_HostKeys;
258
+ if (Array.isArray(keys) && keys.length > 0) return { level: "ok", msg: "Tailscale SSH is on (host keys advertised)", enabled: true };
259
+ return { level: "warn", msg: `Tailscale SSH is off — operators cannot reach this seat remotely; ${remedy}`, enabled: false };
260
+ }
261
+ return { level: "warn", msg: `Tailscale is not installed or not running — no remote operator access; install it, join the tailnet, then ${remedy}`, enabled: null };
262
+ }
263
+
264
+ /**
265
+ * Is the main-session launchd job installed, loaded and alive?
266
+ * @param {{first:string, plists:string[], loaded:string[], heartbeat:object|null, now:number, staleMs?:number}} o
267
+ * plists file names (or labels) in ~/Library/LaunchAgents
268
+ * loaded labels from `launchctl list`
269
+ * @returns {{level:"ok"|"warn"|"fail", msg:string, label:string}}
270
+ */
271
+ export function sessionInstalled({ first, plists = [], loaded = [], heartbeat = null, now, staleMs = SESSION_STALE_MS } = {}) {
272
+ const label = `ai.maestro.${String(first || "").toLowerCase()}-session`;
273
+ const strip = (s) => String(s || "").replace(/\.plist$/, "");
274
+ const installed = plists.map(strip).includes(label);
275
+ if (!installed) {
276
+ return { level: "warn", label, msg: `Main session job ${label} is not installed — the daemon's --print lane is the front door; install with \`maestro session install\`` };
277
+ }
278
+ if (!loaded.map(strip).includes(label)) {
279
+ // Installed-but-unloaded is what a deliberate `maestro session stop`
280
+ // leaves behind (launchctl bootout, plist kept) — so this asks, it does
281
+ // not order the operator to undo their own stop.
282
+ return { level: "warn", label, msg: `Main session job ${label} is installed but not loaded — stopped on purpose (\`maestro session stop\`)? The daemon's --print lane is the front door meanwhile; \`maestro session start\` (launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/${label}.plist) brings it back` };
283
+ }
284
+ if (!heartbeat) {
285
+ return { level: "warn", label, msg: `Main session job ${label} is loaded but has written no heartbeat yet — give it a minute, then \`maestro session status\`` };
286
+ }
287
+ const live = sessionLiveness(heartbeat, now, { staleMs });
288
+ if (!live.sessionLive) {
289
+ const ts = typeof heartbeat.ts === "number" ? heartbeat.ts : Date.parse(heartbeat.ts);
290
+ const age = Number.isFinite(ts) ? Math.round((now - ts) / 1000) : NaN;
291
+ return { level: "fail", label, msg: `Main session job ${label} heartbeat is stale (${Number.isFinite(age) ? `${age} s` : "unreadable"} old) — the daemon is on its fallback lane; \`maestro session status\` / \`maestro session restart\`` };
292
+ }
293
+ const ts = typeof heartbeat.ts === "number" ? heartbeat.ts : Date.parse(heartbeat.ts);
294
+ return { level: "ok", label, msg: `Main session job ${label} loaded, heartbeat live (${Math.round((now - ts) / 1000)} s ago)` };
295
+ }
296
+
297
+ // ---------------------------------------------------------------------------
298
+ // The edge: read, fork, call — then hand off to the pure checks
299
+ // ---------------------------------------------------------------------------
300
+
301
+ function safeRead(p) { try { return readFileSync(p, "utf8"); } catch { return ""; } }
302
+ function safeJson(p) { try { return JSON.parse(readFileSync(p, "utf8")); } catch { return null; } }
303
+
304
+ /** Labels from `launchctl list` output (PID\tStatus\tLabel). */
305
+ function parseLaunchctlList(out) {
306
+ return String(out || "").split("\n").map((l) => l.trim().split(/\s+/).pop()).filter((l) => l && l !== "Label");
307
+ }
308
+
309
+ function agentFirstName(cwd) {
310
+ const aj = safeJson(join(cwd, "config", "agent.json"));
311
+ const fromCfg = aj && (aj.firstName || (aj.name && String(aj.name).split(/\s+/)[0]));
312
+ if (fromCfg) return String(fromCfg).toLowerCase();
313
+ return String(cwd).split("/").pop().replace(/-ai$/, "").toLowerCase();
314
+ }
315
+
316
+ /**
317
+ * Run every check for the agent repo at `cwd`. Each edge is injectable; each
318
+ * is fail-open.
319
+ * @param {object} o
320
+ * @param {string} o.cwd
321
+ * @param {Record<string,string>} [o.env] ambient env (default process.env)
322
+ * @param {number} [o.now]
323
+ * @param {(cmd:string,args:string[],opts:object)=>string} [o.exec] execFileSync-shaped
324
+ * @param {Function} [o.fetchImpl]
325
+ * @param {() => string[]} [o.listPlists] names in ~/Library/LaunchAgents
326
+ * @param {(agentRoot:string)=>object} [o.probeClaude]
327
+ * @returns {Promise<Array<{level:"ok"|"warn"|"fail", msg:string}>>}
328
+ */
329
+ export async function runDoctorChecks(o = {}) {
330
+ const cwd = resolve(o.cwd || process.cwd());
331
+ const env = o.env || process.env;
332
+ const now = Number.isFinite(o.now) ? o.now : Date.now();
333
+ // Default edge: stdout regardless of exit status (`npm ls -g` exits 1 for a
334
+ // package that is NOT installed — and prints the JSON that says so, which is
335
+ // the answer we want); only a spawn error (missing binary, timeout) throws.
336
+ const exec = o.exec || ((cmd, args, opts) => {
337
+ const r = spawnSync(cmd, args, { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 8000, ...opts });
338
+ if (r.error) throw r.error;
339
+ return r.stdout || "";
340
+ });
341
+ const tryExec = (cmd, args, opts) => { try { return String(exec(cmd, args, opts || {}) || ""); } catch { return ""; /* missing binary / non-zero exit → the check degrades to warn */ } };
342
+ const rows = [];
343
+
344
+ // ── Claude auth (F1) ──────────────────────────────────────────────────────
345
+ const dotEnv = parseDotEnv(safeRead(join(cwd, ".env")));
346
+ const merged = { ...pick(env, ["CLAUDE_CODE_OAUTH_TOKEN", "MAESTRO_PREFER_SUBSCRIPTION_AUTH", "ANTHROPIC_API_KEY"]), ...dotEnv };
347
+ const auth = authMode(merged);
348
+ rows.push({ level: auth.level, msg: auth.msg });
349
+ if (auth.mode !== "none") {
350
+ try {
351
+ // The probe judges the mode from the SAME env this row did (.env first,
352
+ // ambient second), so the two rows cannot contradict each other on a
353
+ // seat whose token arrives through launchd's environment.
354
+ const probe = (o.probeClaude || defaultProbeClaude)(cwd, { env });
355
+ if (probe.ok && probe.auth === "unknown") rows.push({ level: "warn", msg: `${probe.name}: ${probe.detail || "could not verify"}` });
356
+ // "verified" only when a real call proved the credential (api-key 200).
357
+ // `claude auth status` reports a login for ANY token value, so a
358
+ // subscription ok is worded as what it is: the CLI's report.
359
+ else if (probe.ok && probe.verified === true) rows.push({ level: "ok", msg: `${probe.name}: verified` });
360
+ else if (probe.ok) rows.push({ level: "ok", msg: `${probe.name}: ${probe.detail || "CLI reports a login (the token is only proven by a real spawn)"}` });
361
+ else rows.push({ level: "fail", msg: `${probe.name}: ${probe.remedy || "failed"}` });
362
+ } catch (err) {
363
+ rows.push({ level: "warn", msg: `Claude auth probe threw: ${err && err.message ? err.message : err}` });
364
+ }
365
+ }
366
+
367
+ // ── Effective Cohort sources (F3) ─────────────────────────────────────────
368
+ const orgYaml = existsSync(join(cwd, "config", "org.yaml")) ? loadOrgConfig(cwd) : null;
369
+ const sources = effectiveSource({ orgYaml, dotEnv, env: pick(env, ["COHORT_BASE", "COHORT_API_URL", "COHORT_ORG_ID", "COHORT_API_TOKEN", "COHORT_TOKEN", "COHORT_API_KEY", "COHORT_AGENT_ID"]) });
370
+ for (const r of sources) {
371
+ if (r.source === "none") continue;
372
+ rows.push({ level: "ok", msg: `Cohort ${r.key} ← ${r.source} (${r.value})` });
373
+ if (r.shadowed.length) rows.push({ level: "warn", msg: ` Cohort ${r.key}: ${r.source} wins over ${r.shadowed.join(", ")} — the losing value is ignored` });
374
+ }
375
+
376
+ // ── COHORT_ORG_ID: ID vs slug (F2) ─────────────────────────────────────────
377
+ const val = (key) => { const r = sources.find((x) => x.key === key); return r && r.source !== "none" ? r.value : ""; };
378
+ const n = (orgYaml && orgYaml.org && orgYaml.org.cohort) || {};
379
+ const base = String(n.base || n.apiUrl || env.COHORT_BASE || env.COHORT_API_URL || dotEnv.COHORT_BASE || dotEnv.COHORT_API_URL || "").replace(/\/+$/, "");
380
+ const token = String(n.token || env.COHORT_API_TOKEN || env.COHORT_TOKEN || env.COHORT_API_KEY || dotEnv.COHORT_API_TOKEN || dotEnv.COHORT_TOKEN || dotEnv.COHORT_API_KEY || "");
381
+ const configuredOrgId = val("orgId");
382
+ if (!base || !token) {
383
+ rows.push({ level: "ok", msg: "Org plane not enrolled (no base/token) — COHORT_ORG_ID check skipped" });
384
+ } else {
385
+ // Deliberately UNPINNED: a slug-valued pin would 401 the very probe that
386
+ // diagnoses it. The key alone selects the tenant server-side.
387
+ let id = "";
388
+ let slug = "";
389
+ let note = "";
390
+ try {
391
+ const res = await call("org.list", {}, { base, token, orgId: "", fetchImpl: o.fetchImpl, logImpl: () => {} });
392
+ const orgs = res && res.ok && res.result && Array.isArray(res.result.orgs) ? res.result.orgs : [];
393
+ const active = orgs.find((x) => x && x.isActive) || orgs[0];
394
+ if (active) { id = String(active.id || ""); slug = String(active.slug || ""); }
395
+ else note = res && !res.ok && res.error ? ` (org.list: ${res.error.code || res.error.message || "error"})` : " (org.list returned no org)";
396
+ } catch (err) { note = ` (org.list failed: ${err && err.message ? err.message : err})`; }
397
+ const shape = orgIdShape(configuredOrgId, id, slug);
398
+ rows.push({ level: shape.level, msg: shape.msg + (id ? "" : note) });
399
+ }
400
+
401
+ // ── Global SDK install + PATH (§3.7 item 6) ───────────────────────────────
402
+ const npmLs = tryExec("npm", ["ls", "-g", SDK_PACKAGE, "--json", "--depth=0"], { timeout: 8000 });
403
+ if (!npmLs) {
404
+ rows.push({ level: "warn", msg: `npm ls -g ${SDK_PACKAGE} failed — could not verify the global SDK install (is npm on PATH?)` });
405
+ } else {
406
+ const latest = tryExec("npm", ["view", SDK_PACKAGE, "version"], { timeout: 8000 });
407
+ const sdk = sdkGlobalInstall(npmLs, latest);
408
+ rows.push({ level: sdk.level, msg: sdk.msg });
409
+ }
410
+ for (const bin of ["maestro", "cohort-mcp"]) {
411
+ const where = tryExec("which", [bin], { timeout: 2000 }).trim();
412
+ if (!where) rows.push({ level: "warn", msg: `\`${bin}\` does not resolve on PATH — a home-dir session cannot reach the agent's tools; npm i -g ${SDK_PACKAGE}@latest` });
413
+ }
414
+
415
+ // ── Tailscale SSH (F4) ────────────────────────────────────────────────────
416
+ let status = "";
417
+ let prefs = "";
418
+ for (const bin of TAILSCALE_BINS) {
419
+ status = tryExec(bin, ["status", "--json"], { timeout: 4000 });
420
+ if (status) { prefs = tryExec(bin, ["debug", "prefs"], { timeout: 4000 }); break; }
421
+ }
422
+ const ts = tailscaleSsh(status, prefs);
423
+ rows.push({ level: ts.level, msg: ts.msg });
424
+
425
+ // ── Main session job (§3.1) ───────────────────────────────────────────────
426
+ const listPlists = o.listPlists || (() => { try { return readdirSync(join(homedir(), "Library", "LaunchAgents")); } catch { return []; } });
427
+ const loaded = parseLaunchctlList(tryExec("launchctl", ["list"], { timeout: 4000 }));
428
+ const heartbeat = safeJson(join(cwd, "state", "session", "heartbeat.json"));
429
+ const sess = sessionInstalled({ first: agentFirstName(cwd), plists: listPlists(), loaded, heartbeat, now });
430
+ rows.push({ level: sess.level, msg: sess.msg });
431
+
432
+ return rows;
433
+ }
434
+
435
+ function pick(obj, keys) {
436
+ const out = {};
437
+ for (const k of keys) if (obj && obj[k]) out[k] = String(obj[k]);
438
+ return out;
439
+ }
440
+
441
+ export default { parseDotEnv, mask, authMode, looksLikeOrgId, orgIdShape, effectiveSource, sdkGlobalInstall, tailscaleSsh, sessionInstalled, runDoctorChecks };