Merge branch 'master' into mod-gui
diff --git a/.rspec b/.rspec
new file mode 100644
index 0000000..ab695a0
--- /dev/null
+++ b/.rspec
@@ -0,0 +1,3 @@
+# ensure that rspec works OK with child spec directory
+
+-I /srv/whimsy/lib/spec
diff --git a/.travis.yml b/.travis.yml
index 91ee289..68c820e 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -43,4 +43,4 @@
   # https://issues.apache.org/jira/browse/INFRA-11080
   # https://github.com/apache/infrastructure-puppet/pull/319
   email:
-  - travis@whimsy-vm4.apache.org
+  - travis@whimsy.apache.org
diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md
index 63bfe16..328610e 100644
--- a/DEPLOYMENT.md
+++ b/DEPLOYMENT.md
@@ -90,25 +90,26 @@
    store the auth-creds.
 
  * Update the following cron scripts under https://svn.apache.org/repos/infra/infrastructure/apmail/trunk/bin:
-     * listmodsubs.sh - if necessary, add an rsync to the old Whimsy host
+     * listmodsubs.sh - add the new host
      * whimsy_qmail_ids.sh - add the new host
-     
+     * the old hosts should be removed sometime after switchover. This approach requires two edits to the files
+     but ensures that the rsync has been tested for the new host and allows the new host to be better tested
+
  * Add the following mail subscriptions:
     * Subscribe `svnupdate@whimsy-vm4.apache.org` to `board-commits@apache.org`.
-      Alternately, add it to the `board-cvs` alias.
-    * Subscribe `svnupdate@whimsy-vm4.apache.org` to 
-      `committers-cvs@apache.org`.
+    * Subscribe `svnupdate@whimsy-vm4.apache.org` to `committers-cvs@apache.org`.
     * Subscribe `board@whimsy-vm4.apache.org` to `board@apache.org`.
     * Subscribe `members@whimsy-vm4.apache.org` to `members@apache.org`.
-    * Add `secretary@whimsy-vm4.apache.org` to the `secretary@apache.org`
-      alias.
-
- * Update the lists of archivers in www/board|members/subscriptions.cgi
+    * Add `secretary@whimsy-vm4.apache.org` to the `secretary@apache.org` alias.
 
  * Using the `www-data` user, copy over the following directories from
-   the previous whimsy-vm* server: `/srv/agenda`, `/srv/mail/board`,
-   ``/srv/icla`, /srv/mail/members`, `/srv/mail/secretary`.
- 
+   the previous whimsy-vm* server:
+   * `/srv/agenda`
+   * `/srv/icla`
+   * `/srv/mail/board`
+   * `/srv/mail/members`
+   * `/srv/mail/secretary`
+
  * Verify that email can be sent to non-apache.org email addresses
    * Run [testmail.rb](tools/testmail.rb)
 
diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md
index e03aca8..bc98e7e 100644
--- a/DEVELOPMENT.md
+++ b/DEVELOPMENT.md
@@ -70,7 +70,7 @@
    `rbenv` or `rvm`.  Rbenv generally requires you to be more aware of what you
    are doing (e.g., the need for rbenv shims).  Rvm tends to be more of a set
    and forget operation, but it tends to be more system intrusive (e.g. aliasing
-   'cd' in bash).  Note the Whimsy server currently uses **ruby 2.3+**.
+   'cd' in bash).  Note the Whimsy server currently uses **ruby 2.5+**.
 
     For more information:
 
@@ -247,6 +247,11 @@
        start on dbus SIGNAL=SessionNew
        exec /srv/whimsy/tools/toucher
        
+4. (Optional) Debug your local Whimsy web environment with two scripts:
+ 
+       localhost:port/test.cgi?debug
+       localhost:port/racktest
+
 More details about the production Whimsy instance are in [DEPLOYMENT.md](DEPLOYMENT.md)
 
 Documentation Standards
diff --git a/MACOSX.md b/MACOSX.md
index 5401545..7740d0c 100644
--- a/MACOSX.md
+++ b/MACOSX.md
@@ -22,14 +22,84 @@
 Homebrew/homebrew-core (git revision 66e9; last commit 2018-04-11)
 ```
 
+Update using:
+
+```
+$ brew update
+```
+
+Homebrew has removed options we need from two of the formulas we need.
+Fix formulas for `openldap` and `apr-util` to make the required options standard.
+Note that we have to remove the bottles otherwise a version of the software is downloaded that does not include the options we require.
+
+```
+$ cd /usr/local/Homebrew/Library/Taps/homebrew/homebrew-core/Formula
+$ # edit apr-util.rb and openldap.rb to make the below diffs
+$ git diff
+diff --git a/Formula/apr-util.rb b/Formula/apr-util.rb
+index 4dee25282..97f460398 100644
+--- a/Formula/apr-util.rb
++++ b/Formula/apr-util.rb
+@@ -5,24 +5,28 @@ class AprUtil < Formula
+   sha256 "d3e12f7b6ad12687572a3a39475545a072608f4ba03a6ce8a3778f607dd0035b"
+   revision 1
+ 
+-  bottle do
+-    sha256 "e4927892e16a3c9cf0d037c1777a6e5728fef2f5abfbc0af3d0d444e9d6a1d2b" => :mojave
+-    sha256 "1bdf0cda4f0015318994a162971505f9807cb0589a4b0cbc7828531e19b6f739" => :high_sierra
+-    sha256 "75c244c3a34abab343f0db7652aeb2c2ba472e7ad91f13af5524d17bba3001f2" => :sierra
+-    sha256 "bae285ada445a2b5cc8b43cb8c61a75e177056c6176d0622f6f87b1b17a8502f" => :el_capitan
+-  end
+ 
+   keg_only :provided_by_macos, "Apple's CLT package contains apr"
+ 
+   depends_on "apr"
+   depends_on "openssl"
++  depends_on "openldap"
+ 
+   def install
+     # Install in libexec otherwise it pollutes lib with a .exp file.
+     system "./configure", "--prefix=#{libexec}",
+                           "--with-apr=#{Formula["apr"].opt_prefix}",
+                           "--with-crypto",
+-                          "--with-openssl=#{Formula["openssl"].opt_prefix}"
++                          "--with-openssl=#{Formula["openssl"].opt_prefix}",
++                         "--with-ldap",
++                         "--with-ldap-lib=#{Formula["openldap"].opt_lib}",
++                         "--with-ldap-include=#{Formula["openldap"].opt_include}"
+     system "make"
+     system "make", "install"
+     bin.install_symlink Dir["#{libexec}/bin/*"]
+diff --git a/Formula/openldap.rb b/Formula/openldap.rb
+index bc6bde9fe..710265ec1 100644
+--- a/Formula/openldap.rb
++++ b/Formula/openldap.rb
+@@ -4,11 +4,11 @@ class Openldap < Formula
+   url "https://www.openldap.org/software/download/OpenLDAP/openldap-release/openldap-2.4.47.tgz"
+   sha256 "f54c5877865233d9ada77c60c0f69b3e0bfd8b1b55889504c650047cc305520b"
+ 
+-  bottle do
+-    sha256 "07e1f0e3ec1a02340a82259e1ace713cfb362126404575032713174935f4140e" => :mojave
+-    sha256 "8901626fc45d76940dec5e516b23d81c9970f4a4a94650bdad60228d604c1b4a" => :high_sierra
+-    sha256 "6dc84ff9e088116201a47adc5c3a2aab28ffd10dbab9d677d49ad7eef1ccc349" => :sierra
+-  end
+ 
+   keg_only :provided_by_macos
+ 
+@@ -35,6 +35,7 @@ class Openldap < Formula
+       --enable-refint
+       --enable-retcode
+       --enable-seqmod
++      --enable-sssvlv=yes
+       --enable-translucent
+       --enable-unique
+       --enable-valsort
+```
+
 Upgrade Ruby
 ------------
 
-Much of Whimsy is written in Ruby.  Install:
-
-```
-$ brew install ruby
-```
+Much of Whimsy is written in Ruby.
 
 Verify:
 
@@ -39,8 +109,22 @@
 ```
 
 If you don't see 2.3.1 or later, run `hash -r` and try again.  If you previously
-installed ruby via brew, you may need to run `brew upgrade ruby` instead.  If you use `rbenv` install via `rbenv install 2.5.0`
+installed ruby via brew, you may need to run `brew upgrade ruby` instead. 
 
+Install:
+
+```
+$ brew install rbenv
+$ rbenv install 2.5.1
+```
+
+Use `ln -s` in `/usr/local/bin` for both `ruby` and `gem` pointing to the locations
+where `rbenv` installed ruby in your home directory
+
+```
+ln -s /usr/local/bin/ruby /Users/${user}/.rbenv/versions/2.5.1/bin/ruby
+ln -s /usr/local/bin/gem /Users/${user}/.rbenv/versions/2.5.1/bin/gem
+```
 
 Upgrade Node.js
 ---------------
@@ -71,7 +155,14 @@
 Install:
 
 ```
-$ gem install whimsy-asf bundler mail listen
+sudo gem install mail listen
+sudo gem install bundler -n /usr/local/bin
+sudo gem install nokogumbo
+sudo gem install passenger sinatra kramdown
+sudo gem install setup
+sudo gem install ruby2js
+sudo gem install rack rake
+sudo gem install crass json sanitize
 ```
 
 Verify:
@@ -165,8 +256,8 @@
 
 ```
 brew install apache-httpd
-brew install openldap --with-sssvlv
-brew reinstall -s apr-util --with-openldap
+brew install openldap # --with-sssvlv
+brew reinstall -s apr-util # --with-openldap
 brew reinstall -s apache-httpd
 ```
 
@@ -202,6 +293,7 @@
     <pre>
     LoadModule proxy_module lib/httpd/modules/mod_proxy.so
     LoadModule proxy_wstunnel_module lib/httpd/modules/mod_proxy_wstunnel.so
+    LoadModule negotiation_module lib/httpd/modules/mod_negotiation.so
     LoadModule speling_module lib/httpd/modules/mod_speling.so
     LoadModule rewrite_module lib/httpd/modules/mod_rewrite.so
     LoadModule expires_module lib/httpd/modules/mod_expires.so
@@ -236,6 +328,8 @@
 the fork() child process. Crashing
 instead.](https://blog.phusion.nl/2017/10/13/why-ruby-app-servers-break-on-macos-high-sierra-and-what-can-be-done-about-it/) message in your `/var/log/apache/error.log` file.  If so, do the following:
 
+On Mojave the failure with forking occurred with Passenger and the following fixes were required.
+
 Edit `/usr/local/opt/httpd/homebrew.mxcl.httpd.plist` and add the following:
 
 ```
@@ -501,6 +595,6 @@
 Debugging
 ---------
 
-When things go wrong, check `/var/log/apache2/whimsy_error.log` and
-`/var/log/apache2/error_log`.
+When things go wrong, either check `whimsy_error.log` and `error_log` in
+either `/usr/local/var/log/httpd/` or `/var/log/apache2/`. The location depends on how you have installed httpd.
 
diff --git a/NOTICE b/NOTICE
index b668de9..94b23c9 100644
--- a/NOTICE
+++ b/NOTICE
@@ -1,5 +1,5 @@
 Apache Whimsy
-Copyright 2016-2018 The Apache Software Foundation
+Copyright 2016-2019 The Apache Software Foundation
 
 This product includes software developed at
 The Apache Software Foundation (http://www.apache.org/).
diff --git a/Rakefile b/Rakefile
index 68b1a2e..e2b492b 100644
--- a/Rakefile
+++ b/Rakefile
@@ -75,7 +75,7 @@
 end
 
 task :config do
-  $LOAD_PATH.unshift File.expand_path('../lib', __FILE__)
+  $LOAD_PATH.unshift '/srv/whimsy/lib'
   require 'whimsy/asf/config'
 end
 
diff --git a/TODOS.md b/TODOS.md
index e223ee3..848a1e3 100644
--- a/TODOS.md
+++ b/TODOS.md
@@ -50,4 +50,3 @@
       Scan curdir; forall *.cgi where second line includes WVisible, display name/link.
       Using a positive comment ensures only scripts wishing to be displayed are visible.
       Effectively done as much is valuable: [www/committers/tools](https://whimsy.apache.org/committers/tools)
-
diff --git a/examples/board.rb b/examples/board.rb
index 3308c90..925f7d1 100644
--- a/examples/board.rb
+++ b/examples/board.rb
@@ -10,7 +10,7 @@
 #
 #   ruby examples/board.rb --install=/Users/rubys/Sites/
 
-$LOAD_PATH.unshift File.realpath(File.expand_path('../../lib', __FILE__))
+$LOAD_PATH.unshift '/srv/whimsy/lib'
 require 'whimsy/asf'
 
 _html do
diff --git a/lib/spec/README.md b/lib/spec/README.md
new file mode 100644
index 0000000..879924c
--- /dev/null
+++ b/lib/spec/README.md
@@ -0,0 +1,7 @@
+Test cases (using rspec) for the library
+
+Run the tests:
+
+$ rspec [-I lib/spec] lib
+
+The -I flag and its parameter can be omitted if they are added to the .rspec file
diff --git a/lib/spec/lib/mail/mail_spec.rb b/lib/spec/lib/mail/mail_spec.rb
new file mode 100644
index 0000000..febd1dd
--- /dev/null
+++ b/lib/spec/lib/mail/mail_spec.rb
@@ -0,0 +1,58 @@
+# encoding: utf-8
+# frozen_string_literal: true
+require 'spec_helper'
+require 'whimsy/asf'
+
+describe ASF::Mail do
+  
+  describe "ASF::Mail.to_canonical" do
+    it "should return address unaltered for invalid emails" do
+      email = 'textwithnoATsign'
+      expect(ASF::Mail.to_canonical(email)).to eq(email)
+      email = 'textwithtrailing@'
+      expect(ASF::Mail.to_canonical(email)).to eq(email)
+      email = '@textwithleadingAT'
+      expect(ASF::Mail.to_canonical(email)).to eq(email)
+    end    
+    it "should return address with downcased domain for valid emails" do
+      expect(ASF::Mail.to_canonical('ABC@DEF')).to eq('ABC@def')
+    end    
+    it "should return address with downcased domain and canonicalised name for GMail emails" do
+      expect(ASF::Mail.to_canonical('A.B.C+123@GMail.com')).to eq('abc@gmail.com')
+    end    
+    it "should return address with downcased domain and canonicalised name for Googlemail emails" do
+      expect(ASF::Mail.to_canonical('A.B.C+123@Googlemail.com')).to eq('abc@gmail.com')
+    end    
+  end
+
+  describe '.cansub(member, pmc_chair, ldap_pmcs)' do
+    lists = ASF::Mail.cansub(false, false, nil)
+    it 'should return public lists only' do
+      whitelist = ['infra-users', 'jobs', 'site-dev', 'committers-cvs', 'site-cvs', 'concom', 'party']
+      board = ['board', 'board-commits', 'board-chat']
+      expect(lists.length).to be >= 1000
+      expect(lists).not_to include('private')
+      expect(lists).not_to include('security')
+      expect(lists).to include(*whitelist)
+      expect(lists).not_to include(*board)
+    end
+    it 'should return the same lists' do
+      mylists = ASF::Mail.cansub(false, false, []) - lists
+      expect(mylists.length).to be(0)
+    end
+    it 'should return private PMC lists' do
+      mylists = ASF::Mail.cansub(false, false, ['ant','whimsical']) - lists
+      expect(mylists.length).to be(2)
+      expect(mylists).to include('ant-private','whimsical-private')
+    end
+    it 'should not return non-existent lists' do
+      mylists = ASF::Mail.cansub(false, false, ['xxxant','xxxwhimsical']) - lists
+      expect(mylists.length).to be(0)
+    end
+    it 'should return private PPMC lists' do
+      podnames = ASF::Podling.current.map(&:name)
+      mylists = ASF::Mail.cansub(false, false, podnames) - lists
+      expect(mylists.length).to be_between(podnames.length-2, podnames.length).inclusive # mailing list may not be set up yet
+    end
+  end
+end
diff --git a/lib/spec/lib/mail/mlist_spec.rb b/lib/spec/lib/mail/mlist_spec.rb
new file mode 100644
index 0000000..859a79c
--- /dev/null
+++ b/lib/spec/lib/mail/mlist_spec.rb
@@ -0,0 +1,85 @@
+# encoding: utf-8
+# frozen_string_literal: true
+require 'spec_helper'
+require 'whimsy/asf'
+require 'whimsy/asf/mlist' # not loaded by default
+
+describe ASF::MLIST do
+
+  describe "ASF::MLIST.members_subscribers" do
+    it "should return an array of members@ subscribers followed by the file update time" do
+      res = ASF::MLIST.members_subscribers()
+      expect(res.class).to eq(Array)
+      expect(res.length).to eq(2)
+      subs,stamp = res
+      expect(subs.class).to eq(Array)
+      expect(stamp.class).to eq(Time)
+      expect(subs.length).to be_between(500, 1000).inclusive
+    end
+  end
+
+  describe "ASF::MLIST.list_archivers" do
+    it "should return array of form [dom, list, [[archiver, type, alias|direct],...]" do
+      ASF::MLIST.list_archivers do |res|
+        expect(res.class).to eq(Array)
+        expect(res.length).to eq(3)
+        dom,list,arches = res # unpack
+        expect(dom.class).to eq(String)
+        expect(list.class).to eq(String)
+        expect(arches.class).to eq(Array)
+        expect(arches[0].length).to eq(3)
+      end
+    end
+  end
+
+  describe "ASF::MLIST.moderates(user_emails, response)" do
+    it "should not find any entries for invalid emails" do
+      user_emails=['user@localhost', 'user@domain.invalid']
+      res = ASF::MLIST.moderates(user_emails)
+      expect(res.length).to eq(2)
+      mods = res[:moderates]
+      expect(mods.length).to eq(0)
+    end
+
+    it "should find some entries for mod-private@gsuite.cloud.apache.org" do
+      user_emails=['mod-private@gsuite.cloud.apache.org']
+      res = ASF::MLIST.moderates(user_emails)
+      expect(res.length).to eq(2)
+      mods = res[:moderates]
+      expect(mods.length).to be_between(8, 20)
+    end
+  end
+
+  describe "ASF::MLIST.subscriptions(user_emails, response)" do
+    it "should not find any entries for invalid emails" do
+      user_emails=['user@localhost', 'user@domain.invalid']
+      res = ASF::MLIST.subscriptions(user_emails)
+      expect(res.length).to eq(2)
+      mods = res[:subscriptions]
+      expect(mods.length).to eq(0)
+    end
+  end
+
+  it "should find lots of entries for archiver@mbox-vm.apache.org" do
+    user_emails=['archiver@mbox-vm.apache.org']
+    res = ASF::MLIST.subscriptions(user_emails)
+    expect(res.length).to eq(2)
+    mods = res[:subscriptions]
+    expect(mods.length).to be_between(1000, 1200)
+  end
+
+  describe "ASF::MLIST.each_list" do
+    it "should return an array of form [[dom, list],...]" do
+      ASF::MLIST.each_list do |res|
+        expect(res.class).to eq(Array)
+        expect(res.length).to eq(2)
+        dom,list = res # unpack
+        expect(dom.class).to eq(String)
+        expect(list.class).to eq(String)
+        expect(dom).to match(/^[a-z.0-9-]+\.[a-z]+$/)
+        expect(list).to match(/^[a-z0-9-]+$/)
+      end
+    end
+  end
+
+end
diff --git a/lib/spec/spec_helper.rb b/lib/spec/spec_helper.rb
new file mode 100644
index 0000000..577cab0
--- /dev/null
+++ b/lib/spec/spec_helper.rb
@@ -0,0 +1,9 @@
+$LOAD_PATH.unshift '/srv/whimsy/lib'
+
+unless defined?(SPEC_ROOT)
+  SPEC_ROOT = File.join(File.dirname(__FILE__))
+end
+
+def fixture_path(*path)
+  File.join SPEC_ROOT, 'fixtures', path
+end
diff --git a/lib/whimsy/asf/agenda/minutes.rb b/lib/whimsy/asf/agenda/minutes.rb
index 3defa68..7dfef54 100644
--- a/lib/whimsy/asf/agenda/minutes.rb
+++ b/lib/whimsy/asf/agenda/minutes.rb
@@ -1,6 +1,9 @@
 # Minutes from previous meetings
 
+
 class ASF::Board::Agenda
+  # Must be outside scan loop
+  FOUNDATION_BOARD = ASF::SVN.find('foundation_board') # Use find to placate Travis
   parse do
     minutes = @file.split(/^ 3. Minutes from previous meetings/,2).last.
       split(OFFICER_SEPARATOR,2).first
@@ -19,9 +22,11 @@
       attrs['text'] = attrs['text'].strip
       attrs['approved'] = attrs['approved'].strip.gsub(/\s+/, ' ')
 
-      file = attrs['text'][/board_minutes[_\d]+\.txt/].untaint
-      if file and File.exist?(File.join(FOUNDATION_BOARD, file))
-        attrs['mtime'] = File.mtime(File.join(FOUNDATION_BOARD, file)).to_i
+      if FOUNDATION_BOARD
+        file = attrs['text'][/board_minutes[_\d]+\.txt/].untaint
+        if file and File.exist?(File.join(FOUNDATION_BOARD, file))
+          attrs['mtime'] = File.mtime(File.join(FOUNDATION_BOARD, file)).to_i
+        end
       end
     end
   end
diff --git a/lib/whimsy/asf/agenda/summary.rb b/lib/whimsy/asf/agenda/summary.rb
index 5429e75..fb0da5f 100644
--- a/lib/whimsy/asf/agenda/summary.rb
+++ b/lib/whimsy/asf/agenda/summary.rb
@@ -1,4 +1,5 @@
 require 'set'
+require 'whimsy/asf/board'
 
 # Creates a summary hash of information from an Agenda
 class ASF::Board::Agenda
@@ -18,41 +19,6 @@
   REPORT_LEN = 'rl'
   APPROVALS_KEY = 'ap'
 
-  INITIALS_IDX = 0
-  # Map director ids->names and ids->initials
-  # Only filled in since 2007 or so, once the preapp data in meetings is parseable
-  DIRECTOR_MAP = {
-    'bayard' => ['hy', 'Henri', 'Henri Yandell'],
-    'bdelacretaz' => ['bd', 'Bertrand', 'Bertrand Delacretaz'],
-    'brett' => ['bp', 'Brett', 'Brett Porter'],
-    'brianm' => ['bmc', 'Brian', 'Brian McCallister'],
-    'cliffs' => ['cs', 'Cliff', 'Cliff Schmidt'],
-    'coar' => ['kc', 'Ken', 'Ken Coar'],
-    'curcuru' => ['sc', 'Shane', 'Shane Curcuru'],
-    'cutting' => ['dc', 'Doug', 'Doug Cutting'],
-    'dirkx' => ['dg', 'Dirk-Willem', 'Dirk-Willem van Gulik'],
-    'dkulp' => ['dk', 'Daniel', 'Daniel Kulp'],
-    'fielding' => ['rf', 'Roy', 'Roy T. Fielding'],
-    'geirm' => ['gmj', 'Geir', 'Geir Magnusson Jr'],
-    'gstein' => ['gs', 'Greg', 'Greg Stein'],
-    'isabel' => ['idf', 'Isabel', 'Isabel Drost-Fromm'],
-    'jerenkrantz' => ['je', 'Justin', 'Justin Erenkrantz'],
-    'jim' => ['jj', 'Jim', 'Jim Jagielski'],
-    'ke4qqq' => ['dn', 'David', 'David Nalley'],
-    'lrosen' => ['lr', 'Larry', 'Lawrence Rosen'],
-    'markt' => ['mt', 'Mark', 'Mark Thomas'],
-    'marvin' => ['mh', 'Marvin', 'Marvin Humphrey'],
-    'mattmann' => ['cm', 'Chris', 'Chris Mattmann'],
-    'noirin' => ['np', 'Noirin', 'Noirin Plunkett'],
-    'psteitz' => ['ps', 'Phil', 'Phil Steitz'],
-    'rbowen' => ['rb', 'Rich', 'Rich Bowen'],
-    'rgardler' => ['rg', 'Ross', 'Ross Gardler'],
-    'rubys' => ['sr', 'Sam', 'Sam Ruby'],
-    'rvs' => ['rs', 'Roman', 'Roman Shaposhnik'],
-    'striker' => ['ss', 'Sander', 'Sander Striker'],
-    'tdunning' => ['td', 'Ted', 'Ted Dunning']
-  }
-
   SKIP_AGENDAS = {
     'board_agenda_2009_11_01' => 'F2F meeting: ApacheCon, St. Helena, CA',
     'board_agenda_2010_09_11' => 'F2F meeting: Boston, MA',
@@ -83,7 +49,7 @@
       summary[PEOPLE_KEY] = Hash[agenda[1][PEOPLE_KEY]]
       summary[PEOPLE_KEY].each do |id, data|
         # Note: this adds initials to everyone who was *ever* a director, who was at this meeting
-        data['initials'] = DIRECTOR_MAP[id][INITIALS_IDX] if DIRECTOR_MAP[id]
+        data['initials'] = ASF::Board.directorInitials(id) if ASF::Board.directorHasId?(id)
       end
     rescue StandardError => e
       summary[ERRORS_KEY] = "ERROR(#{meeting}) no attendance error: #{e.message} #{e.backtrace[0]}"
diff --git a/lib/whimsy/asf/auth.rb b/lib/whimsy/asf/auth.rb
index 32dd2bf..5d94706 100644
--- a/lib/whimsy/asf/auth.rb
+++ b/lib/whimsy/asf/auth.rb
@@ -78,17 +78,4 @@
     end
   end
 
-  class Group
-    # does this group use ou=project?
-    def usesproject?
-      @usesproject ||= ASF::Authorization.new('asf').projects.include?(name)
-    end
-  end
-
-  class Committee
-    # does this committee use ou=project?
-    def usesproject?
-      @usesproject ||= ASF::Authorization.new('pit').projects.include?(name)
-    end
-  end
 end
diff --git a/lib/whimsy/asf/board.rb b/lib/whimsy/asf/board.rb
index dc6ec85..8e6d760 100644
--- a/lib/whimsy/asf/board.rb
+++ b/lib/whimsy/asf/board.rb
@@ -73,5 +73,71 @@
         end
       end
     end
+
+    # Does the uid have an entry in the director intials table?
+    def self.directorHasId?(id)
+      DIRECTOR_MAP[id]
+    end 
+
+    # Return the initials for the uid
+    # Fails if there is no entry, so check first using directorHasId?
+    def self.directorInitials(id)
+      DIRECTOR_MAP[id][INITIALS]
+    end 
+    
+    # Return the first name for the uid
+    # Fails if there is no entry, so check first using directorHasId?
+    def self.directorFirstName(id)
+      DIRECTOR_MAP[id][FIRST_NAME]
+    end
+
+    # Return the display name for the uid
+    # Fails if there is no entry, so check first using directorHasId?
+    def self.directorDisplayName(id)
+      DIRECTOR_MAP[id][DISPLAY_NAME]
+    end 
+
+    private
+
+    # Map director ids->names and ids->initials
+    # Only filled in since 2007 or so, once the preapp data in meetings is parseable
+    INITIALS = 0
+    FIRST_NAME = 1
+    DISPLAY_NAME = 2
+    DIRECTOR_MAP = {
+      'bayard' => ['hy', 'Henri', 'Henri Yandell'],
+      'bdelacretaz' => ['bd', 'Bertrand', 'Bertrand Delacretaz'],
+      'brett' => ['bp', 'Brett', 'Brett Porter'],
+      'brianm' => ['bmc', 'Brian', 'Brian McCallister'],
+      'cliffs' => ['cs', 'Cliff', 'Cliff Schmidt'],
+      'coar' => ['kc', 'Ken', 'Ken Coar'],
+      'curcuru' => ['sc', 'Shane', 'Shane Curcuru'],
+      'cutting' => ['dc', 'Doug', 'Doug Cutting'],
+      'dirkx' => ['dg', 'Dirk-Willem', 'Dirk-Willem van Gulik'],
+      'dkulp' => ['dk', 'Daniel', 'Daniel Kulp'],
+      'druggeri' => ['dr', 'Daniel', 'Daniel Ruggeri'],
+      'fielding' => ['rf', 'Roy', 'Roy T. Fielding'],
+      'geirm' => ['gmj', 'Geir', 'Geir Magnusson Jr'],
+      'gstein' => ['gs', 'Greg', 'Greg Stein'],
+      'isabel' => ['idf', 'Isabel', 'Isabel Drost-Fromm'],
+      'jerenkrantz' => ['je', 'Justin', 'Justin Erenkrantz'],
+      'jim' => ['jj', 'Jim', 'Jim Jagielski'],
+      'ke4qqq' => ['dn', 'David', 'David Nalley'],
+      'lrosen' => ['lr', 'Larry', 'Lawrence Rosen'],
+      'markt' => ['mt', 'Mark', 'Mark Thomas'],
+      'marvin' => ['mh', 'Marvin', 'Marvin Humphrey'],
+      'mattmann' => ['cm', 'Chris', 'Chris Mattmann'],
+      'myrle' => ['mk', 'Myrle', 'Myrle Krantz'],
+      'noirin' => ['np', 'Noirin', 'Noirin Plunkett'],
+      'psteitz' => ['ps', 'Phil', 'Phil Steitz'],
+      'rbowen' => ['rb', 'Rich', 'Rich Bowen'],
+      'rgardler' => ['rg', 'Ross', 'Ross Gardler'],
+      'rubys' => ['sr', 'Sam', 'Sam Ruby'],
+      'rvs' => ['rs', 'Roman', 'Roman Shaposhnik'],
+      'striker' => ['ss', 'Sander', 'Sander Striker'],
+      'tdunning' => ['td', 'Ted', 'Ted Dunning'],
+      'wohali' => ['jt', 'Joan', 'Joan Touzet'],
+    }
+
   end
 end
diff --git a/lib/whimsy/asf/committee.rb b/lib/whimsy/asf/committee.rb
index f2edf2f..b8562f7 100644
--- a/lib/whimsy/asf/committee.rb
+++ b/lib/whimsy/asf/committee.rb
@@ -18,7 +18,7 @@
   # ASF::Committee.load_committee_info is called.
   #
   # Similarly, the simple attributes which are sourced from LDAP is
-  # generally not available until ASF::Committee.preload is called.
+  # generally not available until ASF::Project.preload is called.
 
   class Committee < Base
     # list of chairs for this committee.  Returned as a list of hashes
@@ -83,7 +83,8 @@
     @@namemap = Proc.new do |name|
       # Drop parenthesized comments and downcase before lookup; drop all spaces after lookup
       # So aliases table does not need to contain entries for Traffic Server and XML Graphics.
-      cname = @@aliases[name.sub(/\s+\(.*?\)/, '').downcase].gsub(/\s+/, '')
+      # Also compress white-space before lookup so tabs etc from index.html don't matter
+      cname = @@aliases[name.sub(/\s+\(.*?\)/, '').strip.gsub(/\s+/, ' ').downcase].gsub(/\s+/, '')
       cname
     end
 
@@ -527,5 +528,12 @@
       Committee.load_committee_info # ensure data is there
       Committee.nonpmcs.include? self
     end
+
+    # if true, this committee is a PMC.  
+    # Data is obtained from <tt>committee-info.txt</tt>.
+    def pmc?
+      Committee.load_committee_info # ensure data is there
+      Committee.pmcs.include? self
+    end
   end
 end
diff --git a/lib/whimsy/asf/forms.rb b/lib/whimsy/asf/forms.rb
new file mode 100644
index 0000000..2176038
--- /dev/null
+++ b/lib/whimsy/asf/forms.rb
@@ -0,0 +1,107 @@
+require 'wunderbar'
+require 'wunderbar/markdown'
+
+# Define common page features for whimsy tools using bootstrap styles
+class Wunderbar::HtmlMarkup
+
+  # Utility function to add icons after form controls
+  def _whimsy_forms_iconlink(**args)
+    if args[:iconlink]
+      _div.input_group_btn do
+        _a.btn.btn_default type: 'button', aria_label: "#{iconlabel}", href: "#{args[:iconlink]}", target: 'whimsy_help' do
+          _span.glyphicon class: "#{args[:icon]}", aria_label: "#{args[:iconlabel]}"
+        end
+      end
+    elsif args[:icon]
+      _span.input_group_addon do
+        _span.glyphicon class: "#{args[:icon]}", aria_label: "#{args[:iconlabel]}"
+      end
+    end
+  end
+
+  # Utility function for divs around form controls, including help
+  def _whimsy_control_wrapper(**args)
+    _div.form_group do
+      _label.control_label.col_sm_3 args[:label], for: "#{args[:name]}"
+      _div.col_sm_9 do
+        _div.input_group do
+          yield
+          _whimsy_forms_iconlink(args)
+        end
+        if args[:helptext]
+          _span.help_block id: "#{args[:aria_describedby]}" do
+            _markdown "#{args[:helptext]}"
+          end
+        end
+      end
+    end
+  end
+
+  # Display a single input control within a form; or if rows, then a textarea
+  # @param name required string ID of control's label 
+  def _whimsy_forms_input(**args)
+    return unless args[:name]
+    args[:label] ||= 'Enter string'
+    args[:type] ||= 'text'
+    args[:id] = args[:name]
+    args[:aria_describedby] = "#{args[:name]}_help" if args[:helptext]
+    _whimsy_control_wrapper(args) do
+      args[:class] = 'form-control'
+      if args[:rows]
+        _textarea! args do
+          _! args[:value]
+        end
+      else
+        _input args
+      end
+    end
+  end
+
+  # Display an optionlist control within a form
+  # @param name required string ID of control's label
+  # @param options required ['value'] or {"value" => 'Label for value'} of all selectable values
+  # @param values required 'value' or ['value'] or {"value" => 'Label for value'} of all selected values
+  # @param placeholder Currently displayed text if passed (not selectable)
+  def _whimsy_forms_select(**args)
+    return unless args[:name]
+    return unless args[:values]
+    args[:label] ||= 'Select value(s)'
+    args[:id] = args[:name]
+    args[:aria_describedby] = "#{args[:name]}_help" if args[:helptext]
+    _whimsy_control_wrapper(args) do 
+      if args[:multiple]
+        args[:multiple] = 'true'
+      end
+      _select.form_control args do
+        if ''.eql?(args[:placeholder])
+          _option '', value: '', selected: 'selected'
+        else
+          _option "#{args[:placeholder]}", value: '', selected: 'selected', disabled: 'disabled', hidden: 'hidden'
+        end
+        # Construct selectable list from values (first) then options
+        if args[:values].kind_of?(Array)
+          args[:values].each do |val|
+            _option val, value: val, selected: true
+          end
+        elsif args[:values].kind_of?(Hash)
+          args[:values].each do |val, disp|
+            _option disp, value: val, selected: true
+          end
+        elsif args[:values] # Fallback for simple case of single string value
+          _option "#{args[:values]}", value: "#{args[:values]}", selected: true
+          args[:values] = [args[:values]] # Ensure supports .include? for options loop below
+        end
+        if args[:options].kind_of?(Array)
+          args[:options].each do |val|
+            _option val, value: val unless args[:values].include?(val)
+          end
+        elsif args[:options].kind_of?(Hash)
+          args[:options].each do |val, disp|
+            _option disp, value: val unless args[:values].include?(val)
+          end
+        end
+      end
+    end
+  end
+
+end
diff --git a/lib/whimsy/asf/icla.rb b/lib/whimsy/asf/icla.rb
index 8fd81ab..88ce8c7 100644
--- a/lib/whimsy/asf/icla.rb
+++ b/lib/whimsy/asf/icla.rb
@@ -212,17 +212,20 @@
     end
 
     # list of all availids that are are taken or reserved
+    # See also ASF::Mail.taken?
     def self.availids_taken()
       self.availids_reserved + self.availids
     end
 
     # is the availid taken (in use or reserved)?
+    # See also ASF::Mail.taken?
     def self.taken?(id)
       return self.availids_reserved.include?(id) ||
              self.availids.include?(id)
     end
 
     # is the id available?
+    # See also ASF::Mail.taken?
     def self.available?(id)
       return ! self.taken?(id)
     end
diff --git a/lib/whimsy/asf/ldap.rb b/lib/whimsy/asf/ldap.rb
index b0c0aa1..664b4ca 100644
--- a/lib/whimsy/asf/ldap.rb
+++ b/lib/whimsy/asf/ldap.rb
@@ -394,10 +394,16 @@
     weakref(:pmc_chairs) {Service.find('pmc-chairs').members}
   end
 
-  # Obtain a list of committers from LDAP 
-  # <tt>cn=committers,ou=groups,dc=apache,dc=org</tt>
+  # Obtain a list of committers from LDAP
+  # <tt>cn=committers,ou=role,ou=groups,dc=apache,dc=org</tt>
   def self.committers
-    weakref(:committers) {Group.find('committers').members}
+    weakref(:committers) {RoleGroup.find('committers').members}
+  end
+
+  # Obtain a list of committers from LDAP (old unix group)
+  # <tt>cn=committers,ou=groups,dc=apache,dc=org</tt>
+  def self.oldcommitters
+    weakref(:oldcommitters) {Group.find('committers').members}
   end
 
   # Obtain a list of members from LDAP 
@@ -749,6 +755,11 @@
       attrs['asf-banned'] == 'yes'
     end
 
+    # is the login marked as inactive?
+    def inactive?
+      nologin? || asf_banned?
+    end
+
     # primary mail addresses
     def mail
       attrs['mail'] || []
@@ -781,9 +792,10 @@
     # If the latter, then it needs to be derived from project_owners filtered to keep only PMCs
     def committees
       # legacy LDAP entries
-      committees = weakref(:committees) do
-        Committee.list("member=uid=#{name},#{base}")
-      end
+      committees = []
+#      committees = weakref(:committees) do
+#        Committee.list("member=uid=#{name},#{base}")
+#      end
 
       # add in projects
       # Get list of project names where the person is an owner
@@ -810,6 +822,11 @@
       end
     end
 
+    # list of Podlings that this individual is a member (owner) of
+    def podlings
+      ASF::Podling.current.select{|pod| project_owners.map(&:name).include? pod.name}
+    end
+
     # list of LDAP groups that this individual is a member of
     def groups
       weakref(:groups) do
@@ -1236,39 +1253,10 @@
     end
   end
 
+  # represenation of Committee, i.e. entry in committee-info.txt
+  # includes PMCs and other committees, but does not include podlings
   class Committee < Base
-    # TODO what to do about this? Change to ou=project or drop?
-    # It's used by the methods: self.list, self.preload, member[id]s
-    @base = 'ou=pmc,ou=committees,ou=groups,dc=apache,dc=org'
-
-    # return a list of committees, from LDAP.
-    # TODO this stopped returning all PMCs when guinea pigs were introduced
-    # Should it be dropped, or made to return the list of PMCs ?
-    # No longer used
-    def self.list(filter='cn=*')
-      ASF.search_one(base, filter, 'cn').flatten.map {|cn| Committee.find(cn)}
-    end
-
-    # fetch <tt>dn</tt>, <tt>member</tt>, <tt>modifyTimestamp</tt>, and
-    # <tt>createTimestamp</tt> for all committees in LDAP.
-    # TODO - delete? Not sure it's used anymore
-    def self.preload
-      Hash[ASF.search_one(base, "cn=*", %w(dn member modifyTimestamp createTimestamp)).map do |results|
-        cn = results['dn'].first[/^cn=(.*?),/, 1]
-        committee = ASF::Committee.find(cn)
-        committee.modifyTimestamp = results['modifyTimestamp'].first # it is returned as an array of 1 entry
-        committee.createTimestamp = results['createTimestamp'].first # it is returned as an array of 1 entry
-        members = results['member'] || []
-        committee.members = members
-        [committee, members]
-      end]
-    end
-
-    # Date this committee was last modified in LDAP.
-    attr_accessor :modifyTimestamp
-
-    # Date this committee was initially created in LDAP.
-    attr_accessor :createTimestamp
+    @base = nil # not sure it makes sense to define base here
 
     # return committee only if it actually exists
     def self.[] name
@@ -1277,29 +1265,16 @@
       (ASF::Committee.pmcs+ASF::Committee.nonpmcs).map(&:name).include?(name) ? committee : nil
     end
 
-    # setter for members attribute, should only be used by 
-    # ASF::Committee.preload
-    def members=(members)
-      @members = WeakRef.new(members)
+    # Date this committee was last modified in LDAP.
+    # defer to Project; must have called project.preload
+    def modifyTimestamp
+      ASF::Project[name].modifyTimestamp
     end
 
-    # DEPRECATED.  List of members for this committee.  Use owners as it
-    # is less ambiguous.
-    def members
-      members = weakref(:members) do
-        ASF.search_one(base, "cn=#{name}", 'member').flatten
-      end
-
-      members.map {|uid| Person.find uid[/uid=(.*?),/,1]}
-    end
-
-    # List of ids in the member attribute for this committee
-    def memberids
-      members = weakref(:members) do
-        ASF.search_one(base, "cn=#{name}", 'member').flatten
-      end
-    
-      members.map {|uid| uid[/uid=(.*?),/,1]}
+    # Date this committee was initially created in LDAP.
+    # defer to Project; must have called project.preload
+    def createTimestamp
+      ASF::Project[name].createTimestamp
     end
 
     # List of owners for this committee, i.e. people who are members of the
@@ -1354,26 +1329,6 @@
       @dn ||= ASF::Project.find(name).dn
     end
 
-    # DEPRECATED remove people from a committee.  Call #remove_owners instead.
-    def remove(people)
-      @members = nil
-      people = (Array(people) & members).map(&:dn)
-      return if people.empty?
-      ASF::LDAP.modify(self.dn, [ASF::Base.mod_delete('member', people)])
-    ensure
-      @members = nil
-    end
-
-    # DEPRECATED.  add people to a committee.  Call #add_owners instead.
-    def add(people)
-      @members = nil
-      people = (Array(people) - members).map(&:dn)
-      return if people.empty?
-      ASF::LDAP.modify(self.dn, [ASF::Base.mod_add('member', people)])
-    ensure
-      @members = nil
-    end
-
   end
 
   #
@@ -1507,6 +1462,10 @@
 if __FILE__ == $0
   $LOAD_PATH.unshift '/srv/whimsy/lib'
   require 'whimsy/asf/config'
+  old=ASF.oldcommitters()
+  puts old.length
+  new=ASF.committers()
+  puts new.length
   ASF::RoleGroup.listcns.map {|g| puts ASF::RoleGroup.find(g).dn}
   ASF::AppGroup.listcns.map {|g| puts ASF::AppGroup.find(g).dn}
 end
diff --git a/lib/whimsy/asf/mail.rb b/lib/whimsy/asf/mail.rb
index e3548c0..d06dc0e 100644
--- a/lib/whimsy/asf/mail.rb
+++ b/lib/whimsy/asf/mail.rb
@@ -64,6 +64,11 @@
       public_private ? @lists : @lists.keys
     end
 
+    def self.list_mtime
+      Mail._load_lists
+      @list_mtime
+    end
+
     # list of mailing lists that aren't actively seeking new subscribers
     def self.deprecated
       apmail_bin = ASF::SVN['apmail_bin']
@@ -71,6 +76,9 @@
     end
 
     # which lists are available for subscription via Whimsy?
+    # member: true if member
+    # pmc_chair: true if pmc_chair
+    # ldap_pmcs: list of (P)PMC mail_list names
     def self.cansub(member, pmc_chair, ldap_pmcs)
       Mail._load_lists
       if member
@@ -89,9 +97,10 @@
             lists += ['board', 'board-commits', 'board-chat']
           end
 
-          # PMC members need their private lists
+          # (P)PMC members need their private lists
           if ldap_pmcs
-            lists += ldap_pmcs.map {|lp| "#{lp}-private"}
+            # ensure that the lists actually exist
+            lists += ldap_pmcs.map {|lp| "#{lp}-private"}.select{|l| @lists.keys.include? l}
           end
 
           lists
@@ -125,11 +134,14 @@
       end
     end
 
+    # List of .qmail files that could clash with user ids (See: INFRA-14566)
     def self.qmail_ids
       return [] unless File.exist? '/srv/subscriptions/qmail.ids'
       File.read('/srv/subscriptions/qmail.ids').split
     end
 
+    # Is the id used by qmail?
+    # See also ASF::ICLA.taken?
     def self.taken?(id)
       self.qmail_ids.include? id
     end
@@ -140,7 +152,36 @@
       return list if dom == 'apache.org'
       dom.sub(".apache.org",'-') + list
     end
-    
+
+    # Canonicalise an email address, removing aliases and ignored punctuation
+    # and downcasing the name if safe to do so
+    #
+    # Currently only handles aliases for @gmail.com and @googlemail.com
+    #
+    # All domains are converted to lower-case
+    #
+    # The case of the name part is preserved since some providers may be case-sensitive
+    # Almost all providers ignore case in names, however that is not guaranteed
+    def self.to_canonical(email)
+      parts = email.split('@')
+      if parts.length == 2
+        name, dom = parts
+        return email if name.length == 0 || dom.length == 0
+        dom.downcase!
+        dom = 'gmail.com' if dom == 'googlemail.com' # same mailbox
+        if dom == 'gmail.com'
+          return name.sub(/\+.*/,'').gsub('.','').downcase + '@' + dom
+        else
+          # Effectively the same:
+          dom = 'apache.org' if dom == 'minotaur.apache.org'
+          # only downcase the domain (done above)
+          return name + '@' + dom
+        end
+      end
+      # Invalid; return input rather than failing
+      return email
+    end
+
   end
 
   class Person < Base
@@ -198,6 +239,8 @@
         'trademarks@apache.org'
       when 'infrastructure'
         'infra'
+      when 'dataprivacy'
+        'legal-internal@apache.org'
       when 'legalaffairs'
         'legal-internal@apache.org'
       when 'fundraising'
diff --git a/lib/whimsy/asf/member.rb b/lib/whimsy/asf/member.rb
index 13c256f..fc7af88 100644
--- a/lib/whimsy/asf/member.rb
+++ b/lib/whimsy/asf/member.rb
@@ -27,6 +27,7 @@
     # return a list of <tt>members.txt</tt> entries as a Hash.  Keys are
     # availids.  Values are a Hash with the following keys:
     # <tt>:text</tt>, <tt>:name</tt>, <tt>"status"</tt>.
+    # Active members are those with no 'status' value
     def self.list
       result = Hash[self.new.map {|id, text|
         [id, {text: text, name: self.get_name(text)}]
@@ -50,10 +51,11 @@
       nil
     end
 
-    # Return a hash of non-active ASF members and their status.  Keys are
+    # Return a hash of *non-active* ASF members and their status.  Keys are
     # availids.  Values are strings from the section header under which the
     # member is listed: currently either <tt>Emeritus (Non-voting) Member</tt>
     # or <tt>Deceased Member</tt>.
+    # N.B. Does NOT return active members
     def self.status
       begin
         @status = nil if @mtime != @@mtime
@@ -143,9 +145,11 @@
         @@mtime = 0
       end
 
-      if File.mtime(File.join(foundation, 'members.txt')).to_i > @@mtime.to_i
-        @@mtime = File.mtime(File.join(foundation, 'members.txt'))
-        text = File.read(File.join(foundation, 'members.txt'))
+      member_file = File.join(foundation, 'members.txt')
+      member_time = File.mtime(member_file)
+      if member_time.to_i > @@mtime.to_i
+        @@mtime = member_time
+        text = File.read(member_file)
         @@text = WeakRef.new(text)
       end
 
diff --git a/lib/whimsy/asf/mlist.rb b/lib/whimsy/asf/mlist.rb
index 9f90e78..14308fc 100644
--- a/lib/whimsy/asf/mlist.rb
+++ b/lib/whimsy/asf/mlist.rb
@@ -1,3 +1,5 @@
+require 'weakref'
+
 module ASF
 
   module MLIST
@@ -15,21 +17,32 @@
     # Note that the data files don't provide information on whether a list is
     # public or private.
 
+    @@file_times  = Hash.new # Key=type, value = modtime
+    @@file_parsed = Hash.new # Key=type, value = cache hash
+
     # Return an array of board subscribers followed by the file update time
-    def self.board_subscribers
-      return list_filter('sub', 'apache.org', 'board'), (File.mtime(LIST_TIME) rescue File.mtime(LIST_SUBS))
+    def self.board_subscribers(archivers=true)
+      return list_filter('sub', 'apache.org', 'board', archivers), (File.mtime(LIST_TIME) rescue File.mtime(LIST_SUBS))
     end
 
     # Return an array of members@ subscribers followed by the file update time
-    def self.members_subscribers
-      return list_filter('sub', 'apache.org', 'members'), (File.mtime(LIST_TIME) rescue File.mtime(LIST_SUBS))
+    def self.members_subscribers(archivers=true)
+      return list_filter('sub', 'apache.org', 'members', archivers), (File.mtime(LIST_TIME) rescue File.mtime(LIST_SUBS))
     end
 
     # Return an array of private@pmc subscribers followed by the file update time
     # By default does not return the standard archivers
-    # TODO - does this need to be updated for non-PMC committees?
+    # pmc can either be a pmc name, in which case it uses private@<pmc>.apache.org
+    # or it can be an ASF list name, e.g. w3c@apache.org
     def self.private_subscribers(pmc, archivers=false)
-      return list_filter('sub', "#{pmc}.apache.org", 'private', archivers), (File.mtime(LIST_TIME) rescue File.mtime(LIST_SUBS))
+      parts = pmc.split('@', 3) # want to detect trailing '@'
+      if parts.length == 1
+        return list_filter('sub', "#{pmc}.apache.org", 'private', archivers), (File.mtime(LIST_TIME) rescue File.mtime(LIST_SUBS))
+      elsif parts.length == 2 && parts[1] == 'apache.org'
+        return list_filter('sub', parts[1], parts[0], archivers), (File.mtime(LIST_TIME) rescue File.mtime(LIST_SUBS))
+      else
+        raise "Unexpected parameter: #{pmc}"
+      end
     end
 
     def self.security_subscribers(pmc, archivers=false)
@@ -48,10 +61,11 @@
       response[:subscriptions] = []
       response[:subtime] = (File.mtime(LIST_TIME) rescue File.mtime(LIST_SUBS))
 
+      _emails = emails.map{|email| ASF::Mail.to_canonical(email.downcase)}
       list_parse('sub') do |dom, list, subs|
-        emails.each do |email|
-          if downcase(subs).include? email.downcase
-            response[:subscriptions] << ["#{list}@#{dom}", email]
+        subs.each do |sub|
+          if _emails.include? ASF::Mail.to_canonical(sub.downcase)
+            response[:subscriptions] << ["#{list}@#{dom}", sub]
           end
         end
       end
@@ -70,10 +84,11 @@
       response[:digests] = []
       response[:digtime] = (File.mtime(LIST_TIME) rescue File.mtime(LIST_DIGS))
 
+      _emails = emails.map{|email| ASF::Mail.to_canonical(email.downcase)}
       list_parse('dig') do |dom, list, subs|
-        emails.each do |email|
-          if downcase(subs).include? email.downcase
-            response[:digests] << ["#{list}@#{dom}", email]
+        subs.each do |sub|
+          if _emails.include? ASF::Mail.to_canonical(sub.downcase)
+            response[:digests] << ["#{list}@#{dom}", sub]
           end
         end
       end
@@ -83,7 +98,7 @@
     # return the mailing lists which are moderated by any of the list of emails
     # the following keys are added to the response hash:
     # :modtime - the timestamp when the data was last updated
-    # :moderates - a hash. key: list name; entry: array of moderators
+    # :moderates - a hash. key: list name; entry: array of emails that match a moderator for the list
     # N.B. not the same format as the subscriptions() method
     def self.moderates(user_emails, response = {})
 
@@ -91,9 +106,9 @@
 
       response[:moderates] = {}
       response[:modtime] = (File.mtime(LIST_TIME) rescue File.mtime(LIST_MODS))
-      user_emails.map!{|m| m.downcase} # outside loop
+      umails = user_emails.map{|m| ASF::Mail.to_canonical(m.downcase)} # outside loop
       list_parse('mod') do |dom, list, emails|
-        matching = emails.select{|m| user_emails.include? m.downcase}
+        matching = emails.select{|m| umails.include? ASF::Mail.to_canonical(m.downcase)}
         response[:moderates]["#{list}@#{dom}"] = matching unless matching.empty?
       end
       response
@@ -130,7 +145,16 @@
 
     # for a mail domain, extract related lists and their subscribers (default only the count)
     # also returns the time when the data was last checked
+    # For top-level apache.org lists, the mail_domain is either:
+    # - the full list name (e.g. press), or:
+    # - the list prefix (e.g. legal)
     # If podling==true, then also check for old-style podling names
+    # If list_subs==true, return subscriber emails else sub count
+    # Matches:
+    # {mail_domain}.apache.org/*
+    # apache.org/{mail_domain}(-.*)? (e.g. press, legal)
+    # incubator.apache.org/{mail_domain}-.* (if podling==true)
+    # Returns: {list}@{dom}
     def self.list_subscribers(mail_domain, podling=false, list_subs=false)
 
       return nil, nil unless File.exist? LIST_SUBS
@@ -144,11 +168,14 @@
 
         # normal tlp style:
         #/home/apmail/lists/commons.apache.org/dev/mod
+
         # possible podling styles (new, old):
         #/home/apmail/lists/batchee.apache.org/dev/mod
         #/home/apmail/lists/incubator.apache.org/blur-dev/mod
+
         #Apache lists (e.g. some non-PMCs)
         #/home/apmail/lists/apache.org/list/mod
+
         next unless "#{mail_domain}.apache.org" == dom or
            (dom == 'apache.org' &&  list =~ /^#{mail_domain}(-|$)/) or
            (podling && dom == 'incubator.apache.org' && list =~ /^#{mail_domain}-/)
@@ -157,15 +184,28 @@
       return subscribers.to_h, (File.mtime(LIST_TIME) rescue File.mtime(LIST_SUBS))
     end
 
+    # returns the list time (defaulting to list-subs time if the marker is not present)
+    def self.list_time
+      File.mtime(LIST_TIME) rescue File.mtime(LIST_SUBS)
+    end
+
     def self.list_archivers
       list_parse('sub') do |dom, list, subs|
         yield [dom, list, subs.select {|s| is_archiver? s}.map{|m| [m,archiver_type(m,dom,list)].flatten}]
       end
     end
 
+    # return the [domain, list] for all entries in the subscriber listings
+    # the subscribers are not included 
+    def self.each_list
+      list_parse('sub') do |dom, list, subs|
+        yield [dom, list]
+      end
+    end
+
     private
 
-    # return the archiver type as array: [:MBOX|:PONY|:MINO, 'public'|'private'|'alias'|'direct']
+    # return the archiver type as array: [:MBOX|:PONY|:MINO|:MAIL_ARCH|:MARKMAIL, 'public'|'private'|'alias'|'direct']
     # minotaur archiver names do not include any public/private indication as that is in bin/.archives
     def self.archiver_type(email, dom,list)
       case email
@@ -173,10 +213,13 @@
         when ARCH_MBOX_PRV then return [:MBOX, 'private']
         when ARCH_PONY_PUB then return [:PONY, 'public']
         when ARCH_PONY_PRV then return [:PONY, 'private']
+        when ARCH_EXT_MAIL_ARCHIVE then return [:MAIL_ARCH, 'public']
         # normal archiver routed via .qmail-[tlp-]list-archive
         when "#{list}-archive@#{dom}" then return [:MINO, 'alias']
         # Direct mail to minotaur
         when "apmail-#{dom.split('.').first}-#{list}-archive@www.apache.org" then return [:MINO, 'direct']
+      else
+        return [:MARKMAIL, 'public'] if is_markmail_archiver?(email)
       end
       raise "Unexpected archiver email #{email} for #{list}@#{dom}" # Should not happen?
     end
@@ -186,8 +229,18 @@
       e =~ /.-archive@([^.]+\.)?(apache\.org|apachecon\.com)$/
     end
 
+    # Is the email a Whimsy archiver?
+    def self.is_whimsy_archiver? (e)
+      e =~ /@whimsy(-vm\d+)?\.apache\.org$/
+    end
+
+    # Is the email a markmail archiver?
+    def self.is_markmail_archiver? (e)
+      e =~ ARCH_EXT_MARKMAIL_RE
+    end
+
     def self.is_archiver? (e)
-      ARCHIVERS.include? e or is_mino_archiver? e
+      ARCHIVERS.include?(e) or is_mino_archiver?(e) or is_whimsy_archiver?(e) or is_markmail_archiver?(e)
     end
 
     def self.downcase(array)
@@ -212,7 +265,7 @@
             if archivers
               return emails
             else
-              return (emails - ARCHIVERS) - ["#{list}-archive@#{dom}"]
+            return emails.reject{|e| is_archiver?(e)}
             end
           end
       end
@@ -238,6 +291,24 @@
       else
         raise ArgumentError.new('type: expecting dig, mod or sub')
       end
+      ctime = @@file_times[type] || 0
+      mtime = File.mtime(path).to_i
+      if mtime <= ctime
+        cached = @@file_parsed[type]
+        if cached
+          begin
+            cached.each do |d,l,m|
+              yield d, l, m
+            end
+            return
+          rescue WeakRef::RefError
+            @@file_times[type] = 0
+          end
+        end
+      else
+        @@file_parsed[type] = nil
+      end
+      cache = Array.new # see if this preserves mod cache
       # split file into paragraphs
       File.read(path).split(/\n\n/).each do |stanza|
         # domain may start in column 1 or following a '/'
@@ -249,13 +320,18 @@
           dom = match[1].downcase # just in case
           list = match[2].downcase # just in case
           # Keep original case of email addresses
-          yield dom, list, stanza.scan(/^(.*@.*)/).flatten
+          # TODO: a bit slow for subs file, implement cache of parsed file?
+          mails = stanza.split(/\n/).select{|x| x =~ /@/}
+          cache << [dom, list, mails]
+          yield dom, list, mails
         else
           # don't allow mismatches as that means the RE is wrong
           line=stanza[0..(stanza.index("\n")|| -1)]
           raise ArgumentError.new("Unexpected section header #{line}")
         end
       end
+      @@file_parsed[type] = WeakRef.new(cache)
+      @@file_times[type] = mtime
       nil # don't return file contents
     end
 
@@ -267,8 +343,12 @@
     ARCH_PONY_PUB = "archive-asf-public@cust-asf.ponee.io"
     ARCH_PONY_PRV = "archive-asf-private@cust-asf.ponee.io"
 
+    # Standard external archivers (necessarily public)
+    ARCH_EXT_MAIL_ARCHIVE = "archive@mail-archive.com"
+    ARCH_EXT_MARKMAIL_RE = %r{^\w+\.\w+\.\w+@.\.markmail\.org$} # one.two.three@a.markmail.org
+
     ARCHIVERS = [ARCH_PONY_PRV, ARCH_PONY_PUB,
-                 ARCH_MBOX_PUB, ARCH_MBOX_PRV, ARCH_MBOX_RST]
+                 ARCH_MBOX_PUB, ARCH_MBOX_PRV, ARCH_MBOX_RST, ARCH_EXT_MAIL_ARCHIVE]
     # TODO alias archivers: either add list or use RE to filter them
 
     LIST_MODS = '/srv/subscriptions/list-mods'
diff --git a/lib/whimsy/asf/podling.rb b/lib/whimsy/asf/podling.rb
index 4e1f035..ae81f1b 100644
--- a/lib/whimsy/asf/podling.rb
+++ b/lib/whimsy/asf/podling.rb
@@ -66,8 +66,10 @@
       @reporting = node.at('reporting') if node.at('reporting')
       @monthly = @reporting.text.split(/,\s*/) if @reporting and @reporting.text
 
+      @resolutionLink = node.at('resolution')['link'] if node.at('resolution')
+
       # Note: the following optional elements are not currently processed:
-      # - resolution
+      # - resolution (except for resolution/@link)
       # - retiring/graduating
       # The following podling attributes are not processed:
       # - longname
@@ -90,6 +92,11 @@
       @name || @resource
     end
 
+    # TLP name (name differ from podling name)
+    def tlp_name
+      @resolutionLink || name
+    end
+
     # date this podling was accepted for incubation
     def startdate
       return unless @startdate
@@ -164,7 +171,32 @@
 
     # list of current podlings
     def self.current
-      list.select { |podling| podling.status == 'current' }
+      self._list('current')
+    end
+
+    # list of current podling ids
+    def self.currentids
+      self._listids('current')
+    end
+
+    # list of graduated podlings
+    def self.graduated
+      self._list('graduated')
+    end
+
+    # list of graduated podling ids
+    def self.graduatedids
+      self._listids('graduated')
+    end
+
+    # list of retired podlings
+    def self.retired
+      self._list('retired')
+    end
+
+    # list of retired podling ids
+    def self.retiredids
+      self._listids('retired')
     end
 
     # last modified time of podlings.xml in the local working directory,
@@ -175,7 +207,17 @@
 
     # find a podling by name
     def self.find(name)
-      list.find { |podling| podling.name == name }
+      name = name.downcase
+
+      result = list.find do |podling| 
+        podling.name == name or podling.display_name.downcase == name or
+          podling.resourceAliases.any? {|aname| aname.downcase == name}
+      end
+
+      result ||= list.find do |podling| 
+        podling.resource == name or
+        podling.tlp_name.downcase == name
+      end
     end
 
     # below is for backwards compatibility
@@ -399,5 +441,15 @@
     def namesearch
       Podling.namesearch[display_name]
     end
+    
+    private
+    
+    def self._list(status)
+      list.select { |podling| podling.status == status }
+    end
+
+    def self._listids(status)
+      list.select { |podling| podling.status == status }.map(&:id)
+    end
   end
 end
diff --git a/lib/whimsy/asf/themes.rb b/lib/whimsy/asf/themes.rb
index 9784ac2..c3aef09 100644
--- a/lib/whimsy/asf/themes.rb
+++ b/lib/whimsy/asf/themes.rb
@@ -2,50 +2,7 @@
 
 # Define common page features for whimsy tools using bootstrap styles
 class Wunderbar::HtmlMarkup
-  # DEPRECATED Emit ASF style header with _h1 title and common links
-  def _whimsy_header title, style = :full
-    case style
-    when :mini
-      _div.header do
-        _h1 title
-      end
-    else
-      _div.header.container_fluid do
-        _div.row do
-          _div.col_sm_4.hidden_xs do
-            _a href: 'https://www.apache.org/' do
-              _img title: 'The Apache Software Foundation', alt: 'ASF Logo', width: 250, height: 101,
-                style: "margin-left: 10px; margin-top: 10px;",
-                src: 'https://www.apache.org/foundation/press/kit/asf_logo_small.png'
-            end
-          end
-          _div.col_sm_3.col_xs_3 do
-            _a href: '/' do
-              _img title: 'Whimsy project home', alt: 'Whimsy hat logo', src: '/whimsy.svg', width: 145, height: 101 
-            end
-          end
-          _div.col_sm_5.col_xs_9.align_bottom do 
-            _ul class: 'nav nav-tabs' do
-              _li role: 'presentation' do
-                _a 'Code', href: 'https://github.com/apache/whimsy/'
-              end
-              _li role: 'presentation' do
-                _a 'Questions', href: 'https://lists.apache.org/list.html?dev@whimsical.apache.org'
-              end
-              _li role: 'presentation' do
-                _a 'About', href: '/technology'
-              end
-              _li role: 'presentation' do
-                _span.badge id: 'script-ok'
-              end
-            end
-          end
-        end      
-        _h1 title
-      end
-    end
-  end
-    
+  
   # DEPRECATED Wrap content with nicer fluid margins
   def _whimsy_content colstyle="col-lg-11"
     _div.content.container_fluid do
@@ -57,32 +14,6 @@
     end
   end
   
-  # Emit ASF style footer with (optional) list of related links
-  def _whimsy_footer **args
-    _div.footer.container_fluid do
-      _div.panel.panel_default do 
-        _div.panel_heading do
-          _h3.panel_title 'Related Apache Resources'
-        end
-        _div.panel_body do
-          _ul do
-            if args.key?(:related)
-              args[:related].each do |url, desc|
-                _li do
-                  _a desc, href: url
-                end
-              end
-            else
-              _li do
-                _a 'Whimsy Source Code', href: 'https://github.com/apache/whimsy/'
-              end
-            end
-          end
-        end
-      end
-    end
-  end
-  
   # Emit simplistic copyright footer
   def _whimsy_foot
     _div.footer.container_fluid style: 'background-color: #f5f5f5; padding: 10px;' do
@@ -264,6 +195,34 @@
         end
       end
       _whimsy_foot
-    end    
+    end
   end
+  
+  # Emit wrapper panels for a single tablist accordion item
+  # @param listid of the parent _div.panel_group role: "tablist"
+  # @param itemid of this specific item
+  # @param itemtitle to display in the header panel
+  # @param n unique number of this item (for nav links)
+  # @param itemclass optional panel-success or similar styling
+  def _whimsy_accordion_item(listid: 'accordion', itemid: nil, itemtitle: '', n: 0, itemclass: nil)
+    raise ArgumentError.new("itemid must not be nil") if not itemid
+    args = {id: itemid}
+    args[:class] = itemclass if itemclass
+    _div!.panel.panel_default args do
+      _div!.panel_heading role: "tab", id: "#{listid}h#{n}" do
+        _h4!.panel_title do
+          _a!.collapsed role: "button", data_toggle: "collapse",  aria_expanded: "false", data_parent: "##{listid}", href: "##{listid}c#{n}", aria_controls: "#{listid}c#{n}" do
+            _ "#{itemtitle} "
+            _span.glyphicon.glyphicon_chevron_down id: "#{itemid}-nav"
+          end
+        end
+      end
+      _div!.panel_collapse.collapse id: "#{listid}c#{n}", role: "tabpanel", aria_labelledby: "#{listid}h#{n}" do
+        _div!.panel_body do
+          yield
+        end
+      end
+    end
+  end
+  
 end
diff --git a/lib/whimsy/logparser.rb b/lib/whimsy/logparser.rb
index a538dfe..ebfa073 100755
--- a/lib/whimsy/logparser.rb
+++ b/lib/whimsy/logparser.rb
@@ -13,7 +13,17 @@
   ERROR_LOG_DIR = '/srv/whimsy/www/members/log'
   
   # Constants and ignored regex for whimsy_access logs
-  WHIMSY_APPS = %w(status roster board public secretary)
+  WHIMSY_APPS = {
+    'roster' => 'Roster tool',
+    'board/agenda' => 'Board agenda tool',
+    'board/minutes' => 'Board public minutes',
+    'public' => 'Public JSON files',
+    'secretary' => 'Secretary Workbench',
+    'site.cgi' => 'TLP Site Checker',
+    'pods.cgi' => 'Podling Site Checker',
+    'foundation/orgchart' => 'Public OrgChart',
+    'status' => 'Server Status'
+  }
   RUSER = 'remote_user'
   REFERER = 'referer'
   REMAINDER = 'remainder'
@@ -80,11 +90,11 @@
   
   # Collate/partition whimsy_access entries by app areas
   # @param logs full set of items to scan
-  # @return apps - WHIMSY_APPS categorized, with REMAINDER entry all others
-  def collate_whimsy_access(logs)
+  # @return apps categorized by apphash, with REMAINDER entry all others not captured
+  def collate_whimsy_access(logs, apphash = WHIMSY_APPS)
     remainder = logs
     apps = {}
-    WHIMSY_APPS.each do |a|
+    apphash.keys.each do |a|
       apps[a] = Hash.new{|h,k| h[k] = [] }
       apps[a][RUSER] = Hash.new{|h,k| h[k] = 0 }
       apps[a][REFERER] = Hash.new{|h,k| h[k] = 0 }
diff --git a/repository.yml b/repository.yml
index b84c074..a47de77 100644
--- a/repository.yml
+++ b/repository.yml
@@ -42,6 +42,9 @@
   foundation_board:
     url: private/foundation/board
 
+  foundation_mentors:
+    url: private/foundation/mentors
+
   grants:
     url: private/documents/grants
 
diff --git a/tools/agenda_summary.rb b/tools/agenda_summary.rb
index f26b78f..19d8389 100755
--- a/tools/agenda_summary.rb
+++ b/tools/agenda_summary.rb
@@ -1,6 +1,6 @@
 #!/usr/bin/env ruby
 # Parse board meeting minutes and emit statistics
-$LOAD_PATH.unshift File.realpath(File.expand_path('../../lib', __FILE__))
+$LOAD_PATH.unshift '/srv/whimsy/lib'
 require 'whimsy/asf'
 require 'whimsy/asf/agenda'
 require 'json'
diff --git a/tools/check_auth.rb b/tools/check_auth.rb
index 80d34e5..98bb7db 100755
--- a/tools/check_auth.rb
+++ b/tools/check_auth.rb
@@ -5,9 +5,11 @@
 # - name agrees with ldap query
 # - incorrect alias reference
 
+# allowable non-LDAP names
 ROLE_NAMES =
   %w(buildbot comdev_role projects_role spamassassin_role svn-role acrequser whimsysvn apezmlm puppetsvn apsiteread apsecmail apezmlm smtpd svn rptremind comdev-svn openejb-tck staff
-  sk clr uli nick jim upayavira cpluchino mostarda
+  sk clr uli nick jim upayavira cpluchino mostarda druggeri
+  svn-site-role
 )
 
 DIR = ARGV.first || '/srv/git/infrastructure-puppet/modules/subversion_server/files/authorization'
@@ -17,6 +19,7 @@
   section=''
   names=Hash.new(0)
   IO.foreach(file) { |x|
+    x.chomp!
     next if x =~ /^(#| *$)/
     section='groups' and next if x =~ /^\[groups\]$/
     section='paths'  and next if x =~ /^\[\/\]$/
@@ -44,7 +47,7 @@
         next
       end
     elsif section == 'paths'
-      next if x =~ /^\[((asf:|infra:|private:)?\/\S*)\]$/ # [/path]
+      next if x =~ /^\[((asf:|infra:|private:|bigdata:)?\/\S*)\]$/ # [/path]
       if x =~ /^(?:@(\S+)|\*|(\S+)) *= *r?w? *$/
         if $1
           puts "Undefined name: '#{$1}' in #{x}" unless names.has_key?($1)
diff --git a/tools/check_consistency.rb b/tools/check_consistency.rb
deleted file mode 100755
index aae442b..0000000
--- a/tools/check_consistency.rb
+++ /dev/null
@@ -1,111 +0,0 @@
-#!/usr/bin/env ruby
-
-# basic check of LDAP consistency
-$LOAD_PATH.unshift File.realpath(File.expand_path('../../lib', __FILE__))
-require 'whimsy/asf'
-
-fix = ARGV.delete '--fix'
-
-ASF::LDAP.bind if fix
-
-auth_path=ARGV.shift
-
-groups = ASF::Group.preload # for performance
-# TODO drop soon
-committees = ASF::Committee.preload # for performance
-
-projects = ASF::Project.preload
-summary=Hash.new { |h, k| h[k] = { } }
-projects.keys.each do |entry|
-  summary[entry.name]['p']=1
-end
-
-puts "project.members ~ group.members"
-groups.keys.sort_by {|a| a.name}.each do |entry|
-    summary[entry.name]['g']=1
-    project = ASF::Project[entry.name]
-    if project
-      p = []
-      project.members.sort_by {|a| a.name}.each do |e|
-          p << e.name
-      end
-      g = []
-      entry.members.sort_by {|a| a.name}.each do |e|
-          g << e.name
-      end
-      if p != g
-        puts "#{entry.name}: pm-g=#{p-g} g-pm=#{g-p}" 
-
-        if fix
-          project.add_members(entry.members-project.members) unless (g-p).empty?
-          project.remove_members(project.members-entry.members) unless (p-g).empty?
-        end
-      end
-    end
-end
-
-puts ""
-# TODO will no longer be relevant
-puts "project.owners ~ committee.members"
-committees.keys.sort_by {|a| a.name}.each do |entry|
-    summary[entry.name]['c']=1
-    project = ASF::Project[entry.name]
-    if project
-      p = []
-      project.owners.sort_by {|a| a.name}.each do |e|
-          p << e.name
-      end
-      c = []
-      entry.members.sort_by {|a| a.name}.each do |e|
-          c << e.name
-      end
-      if p != c
-        puts "#{entry.name}: po-c=#{p-c} c-po=#{c-p}" 
-
-        if fix
-          project.add_owners(entry.members-project.owners) unless (c-p).empty?
-          project.remove_owners(project.owners-entry.members) unless (p-c).empty?
-        end
-      end
-    end
-end
-
-puts ""
-puts "current podlings(asf-auth) ~ project(members, owners)"
-pods = Hash[ASF::Podling.list.map {|podling| [podling.name, podling.status]}]
-# flag current podlings to show what records they have
-pods.each do |name,status|
-  summary[name]['pod'] = status if status == 'current'
-end
-# Scan the local defines and report differences
-ASF::Authorization.new('asf',auth_path).each do |grp, mem|
-  summary[grp]['pod'] = pods[grp] + ' (has local definition)'
-  if pods[grp] == 'current'
-    mem.sort!.uniq!
-    project = ASF::Project[grp]
-    if project
-      pm = []
-      project.members.sort_by {|a| a.name}.each do |e|
-          pm << e.name
-      end
-      po = []
-      project.owners.sort_by {|a| a.name}.each do |e|
-          po << e.name
-      end
-      if mem != pm
-        puts "#{grp}: pm-auth=#{pm-mem} auth-pm=#{mem-pm}" 
-      end
-      if mem != po
-        puts "#{grp}: po-auth=#{po-mem} auth-po=#{mem-po}" 
-      end
-    end
-  end
-end
-# Show where names are defined
-puts "\nSummary of name definitions (proj,grp,cttee,status)"
-def show(v,k)
-  v[k] == 1 ? k : '-'
-end
-summary.sort.map do |k,v|
-  puts "#{k.ljust(30)} #{show(v,'p')} #{show(v,'g')} #{show(v,'c')} #{v['pod'] rescue ''}"
-end
\ No newline at end of file
diff --git a/tools/collate_minutes.rb b/tools/collate_minutes.rb
index 190a124..fce80f4 100755
--- a/tools/collate_minutes.rb
+++ b/tools/collate_minutes.rb
@@ -1,5 +1,5 @@
 #!/usr/bin/env ruby
-$LOAD_PATH.unshift File.realpath(File.expand_path('../../lib', __FILE__))
+$LOAD_PATH.unshift '/srv/whimsy/lib'
 puts $LOAD_PATH.first
 
 require 'whimsy/asf'
@@ -35,8 +35,6 @@
 
 # list of SVN resources needed
 resources = {
-  TEMPLATES: 'asf/infrastructure/site/trunk/templates',
-  INCUBATOR_SITE_AUTHOR: 'asf/incubator/public/trunk/content',
   SVN_SITE_RECORDS_MINUTES:
     'asf/infrastructure/site/trunk/content/foundation/records/minutes',
   BOARD: 'private/foundation/board'
@@ -145,13 +143,13 @@
   site[id] = {:name => v['display_name'], :link => v['site'], :text => v['description']}
 end
 
-# parse the calendar for layout info (note: hack for &raquo)
+# parse the calendar for layout info (note: hack for &raquo and &nbsp;)
 CALENDAR = URI.parse 'https://www.apache.org/foundation/board/calendar.html'
 http = Net::HTTP.new(CALENDAR.host, CALENDAR.port)
 http.use_ssl = true
 http.verify_mode = OpenSSL::SSL::VERIFY_NONE
 get = Net::HTTP::Get.new CALENDAR.request_uri
-$calendar = Nokogiri::HTML(http.request(get).body.gsub('&raquo','&#187;'))
+$calendar = Nokogiri::HTML(http.request(get).body.gsub('&raquo','&#187;').gsub('&nbsp;','&#160;'))
 
 # add some style
 style = Nokogiri::XML::Node.new "style", $calendar
@@ -264,6 +262,8 @@
     title.sub! 'TCL', 'Tcl'
     title.sub! 'Orc', 'ORC'
     title.sub! 'Steve', 'STeVe'
+    title.sub! 'Servicecomb', 'ServiceComb'
+    title.sub! 'Zest', 'Polygene'
     title.sub! 'Openmeetings', 'OpenMeetings'
     title.sub! 'Web services', 'Web Services'
     title.sub! 'ASF Rep. for W3C', 'W3C Relations'
@@ -651,6 +651,7 @@
     report.title.sub! 'Apache/TCL', 'Tcl'
     report.title.sub! 'Orc', 'ORC'
     report.title.sub! 'Steve', 'STeVe'
+    report.title.sub! 'Zest', 'Polygene'
     report.title.sub! 'Openmeetings', 'OpenMeetings'
     report.title.sub! 'Ace', 'ACE' # WHIMSY-31
 
diff --git a/tools/deliver.rb b/tools/deliver.rb
index d1dfe8f..d7f1543 100644
--- a/tools/deliver.rb
+++ b/tools/deliver.rb
@@ -8,9 +8,7 @@
 MAIL_ROOT = '/srv/mail'
 
 # get the message ID
-def self.getmid(message)
-  # only search headers for MID
-  hdrs = message[/\A(.*?)\r?\n\r?\n/m, 1] || ''
+def self.getmid(hdrs)
   mid = hdrs[/^Message-ID:.*/i]
   if mid =~ /^Message-ID:\s*$/i # no mid on the first line
     # capture the next line and join them together
@@ -24,10 +22,13 @@
 STDIN.binmode
 mail = STDIN.read
 
+# only search headers for MID and List-ID etc
+hdrs = mail[/\A(.*?)\r?\n\r?\n/m, 1] || ''
+
 # extract info
-dest = mail[/^List-Id: <(.*)>/, 1] || mail[/^Delivered-To.* (\S+)\s*$/, 1] || 'unknown'
+dest = hdrs[/^List-Id: <(.*)>/, 1] || hdrs[/^Delivered-To.* (\S+)\s*$/, 1] || 'unknown'
 month = Time.now.strftime('%Y%m')
-hash = Digest::SHA1.hexdigest(getmid(mail) || mail)[0..9]
+hash = Digest::SHA1.hexdigest(getmid(hdrs) || mail)[0..9]
 
 # build file name
 file = "#{MAIL_ROOT}/#{dest[/^[-\w]+/]}/#{month}/#{hash}"
diff --git a/tools/iclasort.rb b/tools/iclasort.rb
index 6b2950d..ad7ca3a 100644
--- a/tools/iclasort.rb
+++ b/tools/iclasort.rb
@@ -1,4 +1,4 @@
-$LOAD_PATH.unshift File.realpath(File.expand_path('../../lib', __FILE__))
+$LOAD_PATH.unshift '/srv/whimsy/lib'
 require 'whimsy/asf'
 
 OFFICERS = ASF::SVN['officers']
diff --git a/tools/mboxhdr2csv.rb b/tools/mboxhdr2csv.rb
index c7ca8c9..c4fe5b9 100644
--- a/tools/mboxhdr2csv.rb
+++ b/tools/mboxhdr2csv.rb
@@ -7,7 +7,7 @@
 # Count lines of text content in mail body, roughly attempting to 
 #   count just new content (not automated, not > replies)
 
-$LOAD_PATH.unshift File.realpath(File.expand_path('../../lib', __FILE__))
+$LOAD_PATH.unshift '/srv/whimsy/lib'
 require 'whimsy/asf'
 require 'mail'
 require 'csv'
diff --git a/tools/membersort.rb b/tools/membersort.rb
index 98df10d..bd7630b 100644
--- a/tools/membersort.rb
+++ b/tools/membersort.rb
@@ -1,6 +1,6 @@
 # svn update and sort the members.txt file and show the differences
 
-$LOAD_PATH.unshift File.realpath(File.expand_path('../../lib', __FILE__))
+$LOAD_PATH.unshift '/srv/whimsy/lib'
 require 'whimsy/asf'
 
 FOUNDATION = ASF::SVN['foundation']
diff --git a/tools/moderationhelper.rb b/tools/moderationhelper.rb
index 742a007..e7b7720 100755
--- a/tools/moderationhelper.rb
+++ b/tools/moderationhelper.rb
@@ -1,5 +1,5 @@
 #!/usr/bin/env ruby
-$LOAD_PATH.unshift File.realpath(File.expand_path('../../lib', __FILE__))
+$LOAD_PATH.unshift '/srv/whimsy/lib'
 
 =begin
 APP to generate the correct ezmlm syntax for moderators
diff --git a/tools/modify_pmcchairs.rb b/tools/modify_pmcchairs.rb
index ede42bd..69e3459 100755
--- a/tools/modify_pmcchairs.rb
+++ b/tools/modify_pmcchairs.rb
@@ -1,5 +1,5 @@
 #!/usr/bin/env ruby
-$LOAD_PATH.unshift File.realpath(File.expand_path('../../lib', __FILE__))
+$LOAD_PATH.unshift '/srv/whimsy/lib'
 
 #
 # add/remove people from PMC Chairs
diff --git a/tools/monthly_tidy.rb b/tools/monthly_tidy.rb
new file mode 100644
index 0000000..3394d03
--- /dev/null
+++ b/tools/monthly_tidy.rb
@@ -0,0 +1,26 @@
+#!/usr/bin/env ruby
+
+# @(#) monthly tidy-up script
+
+# Script to tidy up directories
+#
+# Deletes files older than 13 months from the following directories:
+# - /srv/mail/board
+# - /srv/mail/members
+
+require 'date'
+require 'fileutils'
+
+keep = (Date.today << 13).strftime('%Y%m')
+
+MAIL = '/srv/mail'
+
+Dir["#{MAIL}/board/20*", "#{MAIL}/members/20*"].each do |dir|
+  if File.basename(dir) < keep
+    begin
+      FileUtils.rm_r dir, :verbose => true
+    rescue => e
+      puts e
+    end
+  end
+end
diff --git a/tools/ponyapi.rb b/tools/ponyapi.rb
index 5c1f696..60519a4 100644
--- a/tools/ponyapi.rb
+++ b/tools/ponyapi.rb
@@ -35,8 +35,8 @@
         File.open(File.join("#{dir}", 'lists.json'), "w") do |f|
           begin
             f.puts JSON.pretty_generate(lists)
-          rescue JSON::GeneratorError
-            puts "WARN:get_pony_lists() threw JSON::GeneratorError, continuing without pretty"
+          rescue JSON::GeneratorError => e
+            puts "WARN:get_pony_lists() #{e.message} #{e.backtrace[0]}, continuing without pretty"
             f.puts lists
           end    
         end
@@ -64,7 +64,7 @@
           begin
             f.puts JSON.pretty_generate(jzon)
           rescue JSON::GeneratorError
-            puts "WARN:get_pony_prefs(#{uri.request_uri}) threw JSON::GeneratorError, continuing without pretty"
+            puts "WARN:get_pony_prefs(#{uri.request_uri}) #{e.message} #{e.backtrace[0]}, continuing without pretty"
             f.puts jzon
           end    
         end
@@ -87,13 +87,18 @@
     uri, request, response = fetch_pony(PONYSTATS % args, cookie)
     if response.code == '200' then
       File.open(File.join(dir, STATSMBOX % args), "w") do |f|
-        jzon = JSON.parse(response.body)
         begin
-          f.puts JSON.pretty_generate(jzon)
-        rescue JSON::GeneratorError
-          puts "WARN:get_pony_stats(#{uri.request_uri}) threw JSON::GeneratorError, continuing without pretty"
-          f.puts jzon
-        end    
+          f.puts JSON.pretty_generate(JSON.parse(response.body))
+        rescue JSON::JSONError
+          begin
+            # If JSON threw error, try again forcing to UTF-8 (may lose data)
+            jzon = JSON.parse(response.body.encode('UTF-8', :invalid => :replace, :undef => :replace))
+            f.puts JSON.fast_generate(jzon, {:max_nesting => false, :indent => ' '})
+          rescue JSON::JSONError => e
+            puts "WARN:get_pony_stats(#{uri.request_uri}) #{e.message} #{e.backtrace[0]}, continuing without pretty"
+            f.puts jzon
+          end
+        end
       end
     else
       puts "ERROR:get_pony_stats(#{uri.request_uri}) returned code #{response.code.inspect}"
diff --git a/tools/ponypoop.rb b/tools/ponypoop.rb
index 55958d7..95612af 100755
--- a/tools/ponypoop.rb
+++ b/tools/ponypoop.rb
@@ -173,6 +173,10 @@
       options[:mbox] = true
     end
     
+    opts.on('-yYEAR', '--year YEAR', 'Only pull down single year, instead of 2010 thru now') do |y|
+      options[:year] = y
+    end
+
     begin
       opts.parse!
     rescue OptionParser::ParseError => e
@@ -189,9 +193,12 @@
 # Main method for command line use
 if __FILE__ == $PROGRAM_NAME
   months = %w( 1 2 3 4 5 6 7 8 9 10 11 12 )
-  years = %w( 2010 2011 2012 2013 2014 2015 2016 2017 )
+  years = %w( 2010 2011 2012 2013 2014 2015 2016 2017 2018 2019 )
   options = optparse
   options[:list] ||= 'board'
+  if options[:year]
+    years = [ options[:year] ]
+  end
   if options[:pull]
     puts "BEGIN: Pulling down stats JSONs in #{options[:dir]} of list: #{options[:list]}@#{options[:subdomain]}"
     PonyAPI::get_pony_stats_many options[:dir], options[:list], options[:subdomain], years, months, options[:cookie]
@@ -200,7 +207,7 @@
     PonyAPI::get_pony_mbox_many options[:dir], options[:list], options[:subdomain], years, months, options[:cookie]
   else
     puts "BEGIN: Analyzing local JSONs in #{options[:dir]} of list: #{options[:list]}"
-    run_analyze_stats options[:dir], options[:list], BOARD_REGEX
+    run_analyze_stats options[:dir], options[:list], 'board'.eql?(options[:list]) ? BOARD_REGEX : {}
   end
   puts "END: Thanks for running ponypoop - see results in #{options[:dir]}"
 end
diff --git a/tools/proxyhelper.rb b/tools/proxyhelper.rb
index 460e1e6..2858dd0 100644
--- a/tools/proxyhelper.rb
+++ b/tools/proxyhelper.rb
@@ -3,7 +3,7 @@
 # TODO Add function to email proxies with their info
 # TODO Add function to cross-check irc log that all proxy/attendee were marked
 
-$LOAD_PATH.unshift File.realpath(File.expand_path('../../lib', __FILE__))
+$LOAD_PATH.unshift '/srv/whimsy/lib'
 require 'whimsy/asf'
 require 'mail'
 
diff --git a/tools/site-scan.rb b/tools/site-scan.rb
index 88cd3d4..234f070 100755
--- a/tools/site-scan.rb
+++ b/tools/site-scan.rb
@@ -6,7 +6,7 @@
 #   See Also: lib/whimsy/sitestandards.rb
 #
 # Makes no value judgements.  Simply extracts raw data for offline analysis.
-$LOAD_PATH.unshift File.realpath(File.expand_path('../../lib', __FILE__))
+$LOAD_PATH.unshift '/srv/whimsy/lib'
 require 'net/http'
 require 'nokogiri'
 require 'json'
diff --git a/tools/svnupdate.rb b/tools/svnupdate.rb
index 6890089..d50a41d 100644
--- a/tools/svnupdate.rb
+++ b/tools/svnupdate.rb
@@ -7,7 +7,8 @@
 
 File.umask(0002)
 
-mail = Mail.new(STDIN.read.encode(crlf_newline: true))
+STDIN.binmode
+mail = Mail.new(STDIN.read)
 
 LOG = '/srv/whimsy/www/logs/svn-update'
 
@@ -35,5 +36,17 @@
     end
   end
 
+elsif mail.subject =~ %r{^bills: r\d+ -( in)? /financials/Bills}
+
+  # prevent concurrent updates being performed by the cron job
+  File.open(LOG, File::RDWR|File::CREAT, 0644) do |log|
+    log.flock(File::LOCK_EX)
+
+    Dir.chdir '/srv/svn/Bills' do
+      `svn cleanup`
+      `svn update`
+    end
+  end
+
 end
 
diff --git a/tools/testmail.rb b/tools/testmail.rb
old mode 100644
new mode 100755
index 3e38571..33680cd
--- a/tools/testmail.rb
+++ b/tools/testmail.rb
@@ -1,3 +1,4 @@
+#!/usr/bin/env ruby
 #
 # Test the ability to send email to non-apache.org email addresses
 #
@@ -8,7 +9,7 @@
 # Note: this will send an email to THAT user.
 #
 
-$LOAD_PATH.unshift File.expand_path('../../lib', __FILE__)
+$LOAD_PATH.unshift '/srv/whimsy/lib'
 require 'whimsy/asf'
 require 'mail'
 require 'etc'
diff --git a/tools/toccomments.sh b/tools/toccomments.sh
index 021f1cb..d62deb6 100755
--- a/tools/toccomments.sh
+++ b/tools/toccomments.sh
@@ -3,7 +3,8 @@
 # Fix incorrectly wrapped comments in Incubator ToC section
 # Intended for use on archived agendas and published minutes.
 
-# Look for Comments: preceeded by non-space
-ruby -p -i -e 'gsub(/(\S)\s+(Comments:)/,"\\1\n     \\2")' "$@"
+# Look for Comments: preceeded by non-space if line contains e.g.  [ ](batchee)
+# N.B. there are some minutes with '[](...)' and some with '[ ] (..)'
+ruby -p -i -e 'gsub(/(\S)\s+(Comments:)/,"\\1\n     \\2") if /^ +\[.?\] ?\(\S+\) /' "$@"
 # no need to save original files as tool is intended for use with files in SVN/Git
-echo "Done; the updated files can be diffed/checked in as required"
\ No newline at end of file
+echo "Done; the updated files can be diffed/checked in as required"
diff --git a/tools/travis-relay.rb b/tools/travis-relay.rb
index 1d6ff9f..f6399c2 100644
--- a/tools/travis-relay.rb
+++ b/tools/travis-relay.rb
@@ -10,7 +10,7 @@
 munge = %w(received delivered-to return-path)
 skip = %w(content-type content-transfer-encoding)
 
-$LOAD_PATH.unshift File.expand_path('../../lib', __FILE__)
+$LOAD_PATH.unshift '/srv/whimsy/lib'
 require 'mail'
 require 'whimsy/asf'
 
diff --git a/tools/update_chairs.rb b/tools/update_chairs.rb
new file mode 100755
index 0000000..5a2ba82
--- /dev/null
+++ b/tools/update_chairs.rb
@@ -0,0 +1,88 @@
+#!/usr/bin/env ruby
+
+# @(#) Script to update foundation/index.txt list of chars from committee-info.json
+
+# Must be run locally at present, and the changes checked in manually
+
+$LOAD_PATH.unshift '/srv/whimsy/lib'
+
+require 'json'
+require 'open-uri'
+require 'whimsy/asf'
+
+cttees=JSON.parse(open('https://whimsy.apache.org/public/committee-info.json').read)['committees']
+chairs={}
+cttees.reject{|k,v| v['pmc'] == false}.each do|k,v|
+    cttee=v['display_name']
+    chairs[cttee]=v['chair'].first[1]['name']
+end
+
+idx=File.join(ASF::SVN['site-root'],'foundation','index.mdtext')
+
+puts "Updating #{idx} to latest copy"
+puts `svn update #{idx}`
+
+puts "Checking if any changes are needed"
+lines=[]
+first=nil # first line of chairs
+last=nil
+changes=[]
+seen=Hash.new{|h,k| h[k]=0}
+open(idx).each_line do |l|
+#   | V.P., Apache Xalan | A. N. Other |
+    m = l.match %r{^\| V.P., \[?Apache (.+?)(\]\(.+?\))? \| (.+?) \|}
+    if m
+       first ||= lines.length
+       last = lines.length
+       name,_,webchair = m.captures
+       cichair = chairs[name]
+       seen[name] += 1
+       unless cichair
+         puts "Cannot find CI entry for #{name}; dropping entry"
+         changes << name
+         next
+       end
+       if seen[name] > 1
+        puts "Duplicate entry for #{name}; dropping"
+        changes << name
+        next
+       end
+       unless webchair == cichair
+           puts "Changing chair for #{name} from #{webchair} to #{cichair}"
+           lines << l.sub(webchair,cichair)
+           changes << name
+           next
+       end
+    end
+    lines << l
+end
+
+notseen = (chairs.keys - seen.keys).sort
+if notseen.length > 0
+  puts "No entry found for: " + notseen.join(',')
+  notseen.each do |e|
+    puts "Adding #{e}"
+    lines.insert last, "| V.P., Apache #{e} | #{chairs[e]} |\n"
+    changes << e
+  end
+  # N.B. Cannot use sort! on slice
+  lines[first..last+notseen.length] = lines[first..last+notseen.length].sort_by do |l| 
+    l.match(/Apache +([^\]|]+)/) do |m| 
+      $1.downcase.gsub(' ','')
+    end
+  end
+end
+
+
+if changes.length > 0
+  puts "Updating the file"
+  File.open(idx,"w") do |f|
+      lines.each {|line| f.print line}
+  end
+  puts "#{idx} was updated; check the diffs:"
+  puts `svn diff #{idx}`
+  puts "Copy/paste the next line to commit the change:"
+  puts "svn ci -m'Changed chairs for: #{changes.join(',')}' #{idx}"  
+else
+  puts "#{idx} not changed"
+end
\ No newline at end of file
diff --git a/tools/vhosttest.rb b/tools/vhosttest.rb
index d43cd11..251435b 100644
--- a/tools/vhosttest.rb
+++ b/tools/vhosttest.rb
@@ -3,7 +3,7 @@
 # preprocess_vhosts.rb puppet macro
 #
 
-$LOAD_PATH.unshift File.realpath(File.expand_path('../../lib', __FILE__))
+$LOAD_PATH.unshift '/srv/whimsy/lib'
 require 'whimsy/asf'
 
 IP = ASF::Git['infrastructure-puppet']
diff --git a/tools/wwwdocs.rb b/tools/wwwdocs.rb
index e85c408..79a3b09 100755
--- a/tools/wwwdocs.rb
+++ b/tools/wwwdocs.rb
@@ -1,6 +1,8 @@
 #!/usr/bin/env ruby
-# Scan all /www scripts for WVisible PAGETITLE and categories
-$LOAD_PATH.unshift File.realpath(File.expand_path('../../lib', __FILE__))
+# Utility function to scan various scripts
+#   Docs: for WVisible PAGETITLE and categories in .cgi
+#   Repos: for ASF::SVN access in .cgi|rb
+$LOAD_PATH.unshift '/srv/whimsy/lib'
 require 'whimsy/asf'
 SCANDIR = "../www"
 ISERR = '!'
@@ -12,6 +14,9 @@
   'ASF Secretarial Team' => 'text-danger'
 }
 AUTHPUBLIC = 'glyphicon-eye-open'
+IS_PRIVATE = /\A(private|infra\/infrastructure)/
+ASFSVN = 'ASF::SVN'
+SCANDIRSVN = "../"
 
 # Return [PAGETITLE, [cat,egories] ] after WVisible; or same as !Bogosity error
 def scan_file(f)
@@ -75,3 +80,55 @@
   auth = get_auth()
   return annotate_scan(scan, auth)
 end
+
+# Read repository.yml for all :svn dirs names to scan for
+# @return [['private1', 'privrepo2', ...], ['public1', 'pubrepo2', ...]
+def read_repository(repofile)
+  svn = YAML.load_file(repofile)[:svn]
+  repos = [[], []]
+  svn.each do |repo, data|
+    data['url'] =~ IS_PRIVATE ? repos[0] << repo : repos[1] << repo
+  end
+  return repos
+end
+
+# Build a regex union from ASFSVN and an array
+# @return Regexp.union(r...)
+def build_regexp(list)
+  r = []
+  list.each do |itm|
+    r << "#{ASFSVN}\['#{itm}']"
+  end
+  return Regexp.union(r)
+end
+
+# Scan file for use of ASF::SVN (private or public)
+# @return [["x = ASF::SVN['Meetings'] # Whole line private repo", ...], [] ]
+def scan_file_svn(f, regexs)
+  repos = [[], []]
+  begin
+    File.open(f).each_line.map(&:chomp).each do |line|
+      if line =~ regexs[0] then
+        repos[0] << line.strip
+      elsif line =~ regexs[1] then
+        repos[1] << line.strip
+      end
+    end
+    return repos
+  rescue Exception => e
+    return [["#{ISERR}Bogosity! #{e.message[0..255]}", "\t#{e.backtrace.join("\n\t")}"],[]]
+  end
+end
+
+# Scan directory for use of ASF::SVN (private or public)
+# @return { file: [['private line'], []] }
+def scan_dir_svn(dir, regexs)
+  links = {}
+  Dir["#{dir}/**/*.{cgi,rb}".untaint].each do |f|
+    l = scan_file_svn(f.untaint, regexs)
+    if (l[0].length + l[1].length) > 0
+      links[f.sub(dir, '')] = l
+    end
+  end
+  return links
+end
diff --git a/www/apmail/mods.cgi b/www/apmail/mods.cgi
index 992be02..c7371c5 100755
--- a/www/apmail/mods.cgi
+++ b/www/apmail/mods.cgi
@@ -1,10 +1,12 @@
 #!/usr/bin/env ruby
 
-$LOAD_PATH.unshift File.realpath(File.expand_path('../../../lib', __FILE__))
+$LOAD_PATH.unshift '/srv/whimsy/lib'
 require 'shellwords'
 require 'whimsy/asf'
 
-# TODO: determine apmail@hermes,wheel@hermes gorup membership
+# TODO: determine apmail@hermes,wheel@hermes group membership
+# TODO: .members. checks are obsolete, but this script does not appear to be usable anyway
+# as apmail and wheel don't exist, nor does /home/apmail/subscriptions/mods
 unless apmail.include? $USER or wheel.include? $USER
   pmc = ENV['PATH_INFO'][/\/([-\w]+)\.apache\.org\//,1]
   pmc &&= ASF::Committee.find(pmc)
diff --git a/www/board/agenda/Gemfile b/www/board/agenda/Gemfile
index 5523355..c6e5087 100644
--- a/www/board/agenda/Gemfile
+++ b/www/board/agenda/Gemfile
@@ -13,7 +13,7 @@
 
 gem 'rake'
 gem 'wunderbar'
-gem 'ruby2js'
+gem 'ruby2js', '>= 3.0.15'
 gem 'sinatra', '~> 2.0'
 gem 'nokogumbo'
 gem 'execjs', ('<2.5.1' if RUBY_VERSION =~ /^1/)
diff --git a/www/board/agenda/Rakefile b/www/board/agenda/Rakefile
index 975c527..2e45ae3 100644
--- a/www/board/agenda/Rakefile
+++ b/www/board/agenda/Rakefile
@@ -1,4 +1,4 @@
-$LOAD_PATH.unshift File.expand_path('../../../../lib', __FILE__)
+$LOAD_PATH.unshift '/srv/whimsy/lib'
 
 # Remove world writable directories that Travis may insert into the PATH,
 # as these cause security errors during testing
diff --git a/www/board/agenda/bin/remind-cronjob.rb b/www/board/agenda/bin/remind-cronjob.rb
index ad9fef2..dc1243b 100644
--- a/www/board/agenda/bin/remind-cronjob.rb
+++ b/www/board/agenda/bin/remind-cronjob.rb
@@ -9,7 +9,7 @@
 
 Dir.chdir File.expand_path('../..', __FILE__)
 
-$LOAD_PATH.unshift File.realpath(File.expand_path('../../../../../../../lib', __FILE__))
+$LOAD_PATH.unshift '/srv/whimsy/lib'
 require 'whimsy/asf/agenda'
 require 'mail'
 require 'listen'
diff --git a/www/board/agenda/models/pending.rb b/www/board/agenda/models/pending.rb
index 3c2c3be..f71db3a 100644
--- a/www/board/agenda/models/pending.rb
+++ b/www/board/agenda/models/pending.rb
@@ -12,7 +12,7 @@
 
     # reset pending when agenda changes
     if agenda and agenda > response['agenda'].to_s
-      response = {'agenda' => agenda}
+      response = {'agenda' => agenda, 'initials' => response['initials']}
     end
 
     # provide empty defaults
diff --git a/www/board/agenda/public/stylesheets/app.css b/www/board/agenda/public/stylesheets/app.css
index aa1e1a7..06793f5 100644
--- a/www/board/agenda/public/stylesheets/app.css
+++ b/www/board/agenda/public/stylesheets/app.css
@@ -548,3 +548,15 @@
   font-family: monospace;
   overflow: hidden;
 }
+
+#email-form input {
+  display: inline;
+  width: 80%;
+  padding-right: 5px;
+  padding-left: 5px;
+}
+
+.reminder .modal-content button {
+  margin-left: 0.5em;
+  margin-bottom: 0em;
+}
diff --git a/www/board/agenda/routes.rb b/www/board/agenda/routes.rb
index cf6f540..2066945 100755
--- a/www/board/agenda/routes.rb
+++ b/www/board/agenda/routes.rb
@@ -54,7 +54,26 @@
 # redirect missing to missing page for the latest agenda
 get '/missing' do
   agenda = dir('board_agenda_*.txt').sort.last
-  pass unless agenda # is this correct?
+  pass unless agenda # this will result in a 404
+
+  # Support for sending out reminders before the agenda is created.
+  # Useful in cases where the agenda creation is delayed due to
+  # a board election.
+  if agenda < Date.today.strftime('board_agenda_%Y_%m_%d.txt')
+    # update in memory cache with a dummy agenda.  The only relevant
+    # part of the agenda that matters for this operation is the list
+    # of pmcs (@pmcs).
+    template = File.read('templates/agenda.erb')
+    @meeting = ASF::Board.nextMeeting
+    agenda = @meeting.strftime('board_agenda_%Y_%m_%d.txt')
+    @directors = ['TBD']
+    @minutes = []
+    @owner = ASF::Board::ShepherdStream.new
+    @pmcs = ASF::Board.reporting(@meeting)
+    contents = Erubis::Eruby.new(template).result(binding)
+    Agenda.update_cache(agenda, nil, contents, true)
+  end
+
   response.headers['Location'] = 
     "#{agenda[/\d+_\d+_\d+/].gsub('_', '-')}/missing"
   status 302
@@ -107,7 +126,7 @@
   Dir[*months.map {|month| "#{month}/*"}].each do |file|
     next unless File.mtime(file) > start
     raw = File.read(file).force_encoding(Encoding::BINARY)
-    next unless raw =~ /Subject: .*Board feedback on 2017-05-17 (.*) report/
+    next unless raw =~ /Subject: .*Board feedback on #{date} (.*) report/
     followup[$1][:count] += 1 if followup[$1]
   end
 
diff --git a/www/board/agenda/spec/reflow_spec.rb b/www/board/agenda/spec/reflow_spec.rb
index 56199ec..d494d47 100644
--- a/www/board/agenda/spec/reflow_spec.rb
+++ b/www/board/agenda/spec/reflow_spec.rb
@@ -37,5 +37,23 @@
 
       expect(page.body).to eq "a?\nb:\nc"
     end
+
+    it "leaves long URLs alone" do
+      @line = "[7] http://example.com" + "/foobar" * 12
+
+      on_vue_server do
+        line = @line
+
+        class TestReflow < Vue
+          def render
+            _ Flow.text(line)
+          end
+        end
+
+        Vue.renderResponse(TestReflow, response)
+      end
+
+      expect(page.body).to eq @line
+    end
   end
 end
diff --git a/www/board/agenda/spec/secretary_spec.rb b/www/board/agenda/spec/secretary_spec.rb
index be589c5..0e9c0b1 100644
--- a/www/board/agenda/spec/secretary_spec.rb
+++ b/www/board/agenda/spec/secretary_spec.rb
@@ -6,7 +6,7 @@
 
 feature 'report' do
   before :each do
-    page.driver.header 'REMOTE_USER', 'clr'
+    page.driver.header 'REMOTE_USER', 'mattsicker' # must be a non-director member of the secretarial team
   end
 
   it "should allow timestamps to be edited" do
diff --git a/www/board/agenda/templates/agenda.erb b/www/board/agenda/templates/agenda.erb
index e33658a..73093f9 100644
--- a/www/board/agenda/templates/agenda.erb
+++ b/www/board/agenda/templates/agenda.erb
@@ -45,8 +45,6 @@
         Ross Gardler
         Tom Pappas
         Sam Ruby
-        Daniel Ruggeri
-        Craig L Russell
         Matt Sicker
         Ulrich Stärk
 
@@ -58,6 +56,7 @@
 
         Jake Farrell
         Daniel Gruno
+        Sally Khudairi
         Kevin A. McGrail
         Greg Stein
 
@@ -90,7 +89,7 @@
 
     C. Treasurer [Ulrich]
 
-    D. Secretary [Craig]
+    D. Secretary [Matt]
 
     E. Executive Vice President [Ross]
 
diff --git a/www/board/agenda/templates/establish.erb b/www/board/agenda/templates/establish.erb
index b8e8681..a4536d0 100644
--- a/www/board/agenda/templates/establish.erb
+++ b/www/board/agenda/templates/establish.erb
@@ -9,8 +9,8 @@
 pursuant to Bylaws of the Foundation; and be it further
 
 RESOLVED, that the Apache Apache <%= @pmcname %> be and hereby is responsible
-for the creation and maintenance of software related to large scale code
-license analysis, auditing and reporting; and be it further
+for the creation and maintenance of software related to <%= @description %>;
+and be it further
 
 RESOLVED, that the office of "Vice President, Apache <%= @pmcname %>" be and
 hereby is created, the person holding such office to serve at the direction of
diff --git a/www/board/agenda/views/actions/email.json.rb b/www/board/agenda/views/actions/email.json.rb
new file mode 100644
index 0000000..5d208b5
--- /dev/null
+++ b/www/board/agenda/views/actions/email.json.rb
@@ -0,0 +1,28 @@
+#
+# send email
+#
+
+ASF::Mail.configure
+
+# extract values for each field
+to, cc, subject, body = @to, @cc, @subject, @body
+
+# construct from address
+sender = ASF::Person.find(env.user)
+from = "#{sender.public_name.inspect} <#{sender.id}@apache.org>".untaint
+
+# construct email
+mail = Mail.new do
+  from from
+  to to
+  cc cc if cc and not cc.empty?
+  subject subject
+
+  body body
+end
+
+# deliver mail
+mail.deliver!
+
+# return email in the response
+{mail: mail.to_s}
diff --git a/www/board/agenda/views/actions/post-data.json.rb b/www/board/agenda/views/actions/post-data.json.rb
index d8c9a99..1ba30f7 100644
--- a/www/board/agenda/views/actions/post-data.json.rb
+++ b/www/board/agenda/views/actions/post-data.json.rb
@@ -7,7 +7,7 @@
 
 # debugging support: enable script to be run from the command line
 if $0 == __FILE__
-  $LOAD_PATH.unshift File.realpath(File.expand_path('../'*6 + 'lib', __FILE__))
+  $LOAD_PATH.unshift '/srv/whimsy/lib'
   Dir.chdir File.expand_path('../..', __dir__)
   require './helpers/string'
   require 'whimsy/asf'
diff --git a/www/board/agenda/views/actions/post.json.rb b/www/board/agenda/views/actions/post.json.rb
index 9a2ee84..e59cb78 100644
--- a/www/board/agenda/views/actions/post.json.rb
+++ b/www/board/agenda/views/actions/post.json.rb
@@ -89,7 +89,7 @@
     agenda[/^()-+\nEnd of agenda/, 1] = 
       "-----------------------------------------\n" +
       "Attachment #{attach}: Report from the Apache #{pmc.display_name} " +
-      "Project  [#{pmc.chair.public_name}]\n" +
+      "Project  [#{pmc.chair.public_name}]\n\n" +
       "#{@report.strip}\n\n"
 
   else
diff --git a/www/board/agenda/views/app.js.rb b/www/board/agenda/views/app.js.rb
index 41d5b82..2046d34 100644
--- a/www/board/agenda/views/app.js.rb
+++ b/www/board/agenda/views/app.js.rb
@@ -22,6 +22,7 @@
 require_relative 'pages/search'
 require_relative 'pages/comments'
 require_relative 'pages/help'
+require_relative 'pages/secrets'
 require_relative 'pages/shepherd'
 require_relative 'pages/queue'
 require_relative 'pages/flagged'
diff --git a/www/board/agenda/views/buttons/email.js.rb b/www/board/agenda/views/buttons/email.js.rb
index 3ae3a7b..d38008f 100644
--- a/www/board/agenda/views/buttons/email.js.rb
+++ b/www/board/agenda/views/buttons/email.js.rb
@@ -3,9 +3,15 @@
 #
 
 class Email < Vue
+  def initialize
+    @email = {}
+  end
+
   def render
     _button.btn 'send email', class: self.mailto_class(),
       onClick: self.launch_email_client
+
+    _EmailForm email: @email, id: @@item.mail_list
   end
 
   # render 'send email' as a primary button if the viewer is the shepherd for
@@ -31,7 +37,7 @@
   end
 
   # launch email client, pre-filling the destination, subject, and body
-  def launch_email_client()
+  def launch_email_client(event)
     mail_list = @@item.mail_list
     mail_list = "private@#{mail_list}.apache.org" unless mail_list.include? '@'
 
@@ -77,8 +83,66 @@
       end
     end
 
-    window.location = "mailto:#{to}?cc=#{cc}" +
-      "&subject=#{encodeURIComponent(subject)}" +
-      "&body=#{encodeURIComponent(body)}"
+    if event.ctrlKey or event.shiftKey or event.metaKey
+      @email = {
+        to: to,
+        cc: cc,
+        subject: subject,
+        body: body
+      }
+
+      jQuery('#email-' + @@item.mail_list).modal(:show)
+    else
+      window.location = "mailto:#{to}?cc=#{cc}" +
+        "&subject=#{encodeURIComponent(subject)}" +
+        "&body=#{encodeURIComponent(body)}"
+    end
+  end
+end
+
+class EmailForm < Vue
+  def render
+    _ModalDialog color: 'commented', id: 'email-' + @@id do
+      _h4 "Send email - #{@@email.subject}"
+
+      # input field: to
+      _div.form_group.row do
+        _label.col_sm_2 'To', for: 'email-to'
+        _input.col_sm_10.email_to! placeholder: "destination email address",
+          disabled: @disabled, value: @@email.to
+      end
+
+      # input field: cc
+      _div.form_group.row do
+        _label.col_sm_2 'CC', for: 'email-cc'
+        _input.col_sm_10.email_cc! placeholder: "cc list", disabled: @disabled,
+          value: @@email.cc
+      end
+
+      # input field: subject
+      _div.form_group.row do
+        _label.col_sm_2 'Subject', for: 'email-subject'
+        _input.col_sm_10.email_subject! placeholder: "email subject",
+        disabled: @disabled, value: @@email.subject
+      end
+
+      # input field: body
+      _textarea.email_body! label: 'Body', placeholder: "email text",
+        disabled: @disabled, value: @@email.body, rows: 10
+
+      _button.btn_default 'Cancel', type: 'button', data_dismiss: 'modal'
+      _button.btn_primary 'Send', type: 'button', onClick: self.send,
+        disabled: @disabled
+    end
+  end
+
+  def send(event)
+    @disabled = true
+    post 'email', @@email do |response|
+      console.log response
+      @disabled = false
+      jQuery('#email-' + @@id).modal(:hide)
+      document.body.classList.remove('modal-open')
+    end
   end
 end
diff --git a/www/board/agenda/views/buttons/timestamp.js.rb b/www/board/agenda/views/buttons/timestamp.js.rb
index ca3cb5c..e4bdb5e 100644
--- a/www/board/agenda/views/buttons/timestamp.js.rb
+++ b/www/board/agenda/views/buttons/timestamp.js.rb
@@ -22,6 +22,7 @@
     post 'minute', data do |minutes|
       @disabled = false
       Minutes.load minutes
+      Todos.load() if Minutes.complete
     end
   end
 end
diff --git a/www/board/agenda/views/models/agenda.js.rb b/www/board/agenda/views/models/agenda.js.rb
index 4f01956..a28898a 100644
--- a/www/board/agenda/views/models/agenda.js.rb
+++ b/www/board/agenda/views/models/agenda.js.rb
@@ -246,7 +246,7 @@
 
     if @attach =~ /^[A-Z]+$/
       Agenda.index.each do |item|
-        items << item if item.attach =~ /^7/ and item.roster == @roster
+        items << item if item.attach =~ /^7\w/ and item.roster == @roster
       end
     end
 
diff --git a/www/board/agenda/views/pages/adjournment.js.rb b/www/board/agenda/views/pages/adjournment.js.rb
index 99f0e7d..578c44e 100644
--- a/www/board/agenda/views/pages/adjournment.js.rb
+++ b/www/board/agenda/views/pages/adjournment.js.rb
@@ -142,8 +142,9 @@
   end
 
   # fetch secretary todos once the minutes are complete
-  def mounted()
-    if Minutes.complete and Todos.loading and not Todos.fetched
+  def load()
+    if Minutes.complete and not Todos.fetched
+      Todos.loading = true
       Todos.fetched = true
       retrieve "secretary-todos/#{Agenda.title}", :json do |todos|
         Todos.set todos
@@ -151,6 +152,10 @@
       end
     end
   end
+
+  def mounted()
+    self.load() if Todos.loading
+  end
 end
 
 class PMCActions < Vue
diff --git a/www/board/agenda/views/pages/help.js.rb b/www/board/agenda/views/pages/help.js.rb
index e26eb75..4d64506 100644
--- a/www/board/agenda/views/pages/help.js.rb
+++ b/www/board/agenda/views/pages/help.js.rb
@@ -61,6 +61,9 @@
         end
       end
     end
+
+    _br
+    _Link text: 'Insider Secrets', href: 'secrets'
   end
 
   def setRole(event)
diff --git a/www/board/agenda/views/pages/secrets.js.rb b/www/board/agenda/views/pages/secrets.js.rb
new file mode 100644
index 0000000..3d49b3e
--- /dev/null
+++ b/www/board/agenda/views/pages/secrets.js.rb
@@ -0,0 +1,37 @@
+class InsiderSecrets < Vue
+  def render
+    _p %q(
+      Following are some of the less frequently used features that aren't
+      prominently highlighted by the UI, but you might find useful.
+    )
+
+    _ul do
+      _li { _p %q(
+        Want to reflow only part of a report so as to not mess with the
+        formatting of a table or other pre-formatted text?  Select the
+        lines you want to adjust using your mouse before pressing the
+        reflow button.
+      ) }
+
+      _li { _p %q(
+        Want to not use your email client for whatever reason?  Press
+        shift before you click a 'send email' button and a form will
+        drop down that you can use instead.
+      ) }
+
+      _li { _p %q(
+        Action items have both a status (which is either shown with a red
+        background if no update has been made or a white background if
+        a status has been provided), and a PMC name.  The background of the
+        later is either grey if this PMC is not reporting this month, or
+        a link to the report itself, and the color of the link is the color
+        associated with the report (green if preapproved, red if flagged,
+        etc.).  So generally if you see an action item to "pursue a report
+        for..." and the link is green, you can confidently mark that action as
+        complete.
+      ) }
+    end
+  end
+
+  _Link text: 'Back to the agenda', href: '.'
+end
diff --git a/www/board/agenda/views/router.js.rb b/www/board/agenda/views/router.js.rb
index 729f518..15cc195 100644
--- a/www/board/agenda/views/router.js.rb
+++ b/www/board/agenda/views/router.js.rb
@@ -88,6 +88,9 @@
       # Progressive Web Application 'Add to Home Screen' support
       item.buttons = [{button: Install}] if PageCache.installPrompt
 
+    elsif path == 'secrets'
+      item = {view: InsiderSecrets}
+
     elsif path == 'bootstrap.html'
       item = {view: BootStrapPage, title: ' '}
 
diff --git a/www/board/agenda/views/utils.js.rb b/www/board/agenda/views/utils.js.rb
index 7bead82..cc90386 100644
--- a/www/board/agenda/views/utils.js.rb
+++ b/www/board/agenda/views/utils.js.rb
@@ -57,7 +57,11 @@
           elsif xhr.response.exception
             message = "Exception\n#{xhr.response.exception}"
           else
-            message = "Exception\n#{JSON.parse(xhr.responseText).exception}"
+            begin
+              message = "Exception\n#{JSON.parse(xhr.responseText).exception}"
+            rescue
+              message = "Exception\n#{xhr.responseText}"
+            end
           end
 
           console.log(message)
@@ -184,12 +188,16 @@
           gsub(/(.{1,#{len}})( +|$\n?)/, "$1\n").
           sub(/[\n\r]+$/, '')
       else
-        # preserve indentation.
-        n = len - prefix.length;
-        indent = prefix.gsub(/\S/, ' ')
-        lines[i] = prefix + line[prefix.length..-1].
-          gsub(/(.{1,#{n}})( +|$\n?)/, indent + "$1\n").
-          sub(indent, '').sub(/[\n\r]+$/, '')
+        # ensure line can be split after column 40
+        lastspace = /^.*\s\S/.exec(line)
+        if lastspace and lastspace[0].length - 1 > 40
+          # preserve indentation.
+          n = len - prefix.length;
+          indent = prefix.gsub(/\S/, ' ')
+          lines[i] = prefix + line[prefix.length..-1].
+            gsub(/(.{1,#{n}})( +|$\n?)/, indent + "$1\n").
+            sub(indent, '').sub(/[\n\r]+$/, '')
+        end
       end
     end
 
diff --git a/www/board/missing-reports.cgi b/www/board/missing-reports.cgi
index e13c097..28a6eea 100755
--- a/www/board/missing-reports.cgi
+++ b/www/board/missing-reports.cgi
@@ -1,6 +1,6 @@
 #!/usr/bin/env ruby
 
-$LOAD_PATH.unshift File.realpath(File.expand_path('../../../lib', __FILE__))
+$LOAD_PATH.unshift '/srv/whimsy/lib'
 require 'whimsy/asf/agenda'
 
 records = 'http://www.apache.org/foundation/records/minutes/'
diff --git a/www/board/posted-reports.cgi b/www/board/posted-reports.cgi
index f2e3de7..4db5b5d 100755
--- a/www/board/posted-reports.cgi
+++ b/www/board/posted-reports.cgi
@@ -1,6 +1,6 @@
 #!/usr/bin/env ruby
 
-$LOAD_PATH.unshift File.realpath(File.expand_path('../../../lib', __FILE__))
+$LOAD_PATH.unshift '/srv/whimsy/lib'
 require 'date'
 require 'mail'
 require 'wunderbar'
diff --git a/www/board/publish_minutes.cgi b/www/board/publish_minutes.cgi
index 8cbf491..aaf5597 100755
--- a/www/board/publish_minutes.cgi
+++ b/www/board/publish_minutes.cgi
@@ -1,6 +1,6 @@
 #!/usr/bin/env ruby
 
-$LOAD_PATH.unshift File.realpath(File.expand_path('../../../lib', __FILE__))
+$LOAD_PATH.unshift '/srv/whimsy/lib'
 require 'wunderbar'
 require 'date'
 require 'whimsy/asf'
diff --git a/www/board/subscriptions.cgi b/www/board/subscriptions.cgi
index d0d6f0a..690c6af 100755
--- a/www/board/subscriptions.cgi
+++ b/www/board/subscriptions.cgi
@@ -1,24 +1,15 @@
 #!/usr/bin/env ruby
 PAGETITLE = "Board@ List CrossCheck - PMC Chairs" # Wvisible:board,mail
 
-$LOAD_PATH.unshift File.expand_path('../../../lib', __FILE__)
+$LOAD_PATH.unshift '/srv/whimsy/lib'
 require 'wunderbar'
 require 'wunderbar/bootstrap'
 require 'whimsy/asf'
 require 'whimsy/asf/mlist'
 
-ARCHIVERS = %w(
-  private@mbox-vm.apache.org
-  board-archive@apache.org
-  archive-asf-private@cust-asf.ponee.io
-  board@mmpoc.apache.org
-  board@whimsy-vm4.apache.org
-  svnupdate@whimsy-vm4.apache.org
-)
-
 info_chairs = ASF::Committee.load_committee_info.group_by(&:chair)
 ldap_chairs = ASF.pmc_chairs
-subscribers, modtime = ASF::MLIST.board_subscribers
+subscribers, modtime = ASF::MLIST.board_subscribers(false) # excluding archivers
 
 _html do
   _body? do
@@ -95,7 +86,6 @@
         end
         _tbody do
           ids.sort.each do |id, person, email|
-            next if ARCHIVERS.include? email
             _tr_ do
               href = "/roster/committer/#{id}"
               if person.asf_member?
diff --git a/www/brand/list.cgi b/www/brand/list.cgi
index dee53a3..5bc18fd 100755
--- a/www/brand/list.cgi
+++ b/www/brand/list.cgi
@@ -4,7 +4,7 @@
 # return output in JSON format if the query string includes 'json'
 ENV['HTTP_ACCEPT'] = 'application/json' if ENV['QUERY_STRING'].include? 'json'
 
-$LOAD_PATH.unshift File.realpath(File.expand_path('../../../lib', __FILE__))
+$LOAD_PATH.unshift '/srv/whimsy/lib'
 require 'csv'
 require 'json'
 require 'whimsy/asf'
diff --git a/www/brand/replyedit.cgi b/www/brand/replyedit.cgi
index e2332ed..b4fc49a 100755
--- a/www/brand/replyedit.cgi
+++ b/www/brand/replyedit.cgi
@@ -1,6 +1,6 @@
 #!/usr/bin/env ruby
 PAGETITLE = "DEMO: proposed UI for editing a response to question from boilerplate"
-$LOAD_PATH.unshift File.realpath(File.expand_path('../../../lib', __FILE__))
+$LOAD_PATH.unshift '/srv/whimsy/lib'
 require 'whimsy/asf'
 require 'wunderbar'
 require 'wunderbar/bootstrap'
diff --git a/www/brand/replylist.cgi b/www/brand/replylist.cgi
index 1caa1df..b854be2 100755
--- a/www/brand/replylist.cgi
+++ b/www/brand/replylist.cgi
@@ -1,6 +1,6 @@
 #!/usr/bin/env ruby
 PAGETITLE = "DEMO: proposed UI for mailing list view for reply features"
-$LOAD_PATH.unshift File.realpath(File.expand_path('../../../lib', __FILE__))
+$LOAD_PATH.unshift '/srv/whimsy/lib'
 require 'whimsy/asf'
 require 'wunderbar'
 require 'wunderbar/bootstrap'
diff --git a/www/brand/replyui.cgi b/www/brand/replyui.cgi
index 1786ca1..16a679c 100755
--- a/www/brand/replyui.cgi
+++ b/www/brand/replyui.cgi
@@ -1,6 +1,6 @@
 #!/usr/bin/env ruby
 PAGETITLE = "DEMO: proposed UI for popup/dialog to choose a reply boilerplate"
-$LOAD_PATH.unshift File.realpath(File.expand_path('../../../lib', __FILE__))
+$LOAD_PATH.unshift '/srv/whimsy/lib'
 require 'whimsy/asf'
 require 'wunderbar'
 require 'wunderbar/bootstrap'
diff --git a/www/committers/index.cgi b/www/committers/index.cgi
new file mode 100755
index 0000000..1d8378a
--- /dev/null
+++ b/www/committers/index.cgi
@@ -0,0 +1,61 @@
+#!/usr/bin/env ruby
+PAGETITLE = "Overview of Whimsy Tools for Committers" # Wvisible:tools
+
+$LOAD_PATH.unshift '/srv/whimsy/lib'
+require 'json'
+require 'whimsy/asf'
+require 'wunderbar'
+require 'wunderbar/bootstrap'
+
+MISC = {
+  'tools.cgi' => "Listing of all available Whimsy tools",
+  'subscribe.cgi' => "Subscribe or unsubscribe from mailing lists",
+  'svn-info.cgi' => "Try some Subversion commands from the browser",
+  'moderationhelper.cgi' => "Get help with mailing list moderation commands"
+}
+_html do
+  _body? do
+    _whimsy_body(
+      title: PAGETITLE,
+      subtitle: 'Committer-restricted tools only',
+      relatedtitle: 'More Useful Links',
+      related: {
+        "/committers/tools" => "Whimsy All Tools Listing",
+        "https://svn.apache.org/repos/private/committers/" => "Checkout the private 'committers' repo for Committers",
+        "https://github.com/apache/whimsy/blob/master/www#{ENV['SCRIPT_NAME']}" => "See This Source Code",
+        "mailto:dev@whimsical.apache.org?subject=[FEEDBACK] members/index idea" => "Email Feedback To dev@whimsical"
+      },
+      helpblock: -> {
+        _p %{
+          This script lists various Whimsy tools restricted to Committers.  These all deal with private or 
+          sensitive data, so be sure to keep confidential and do not share with non-committers.
+        }
+        _p do
+          _ 'More questions?  See the '
+          _a '/dev developer info pages', href: 'https://www.apache.org/dev/'
+          _ ' or ask the '
+          _a 'Community Development PMC', href: 'https://community.apache.org/'
+          _ ' for pointers to everything Apache.'
+        end
+      },
+      breadcrumbs: {
+        members: '/committers/tools#members',
+        meeting: '/committers/tools#meeting'
+      }
+    ) do
+    
+      _h2 "Useful Committer-only Tools (require login)"
+      _ul do
+        MISC.each do |url, desc|
+          _li do
+            _a desc, href: url
+            _ ' - '
+            _code! do
+              _a url, href: url
+            end
+          end
+        end
+      end
+    end
+  end
+end
diff --git a/www/committers/ldap-map.cgi b/www/committers/ldap-map.cgi
index 8736a80..b3169b4 100755
--- a/www/committers/ldap-map.cgi
+++ b/www/committers/ldap-map.cgi
@@ -1,7 +1,7 @@
 #!/usr/bin/env ruby
 PAGETITLE = "Mapping Committer IDs In JIRA and Confluence" # Wvisible:tools 
 
-$LOAD_PATH.unshift File.realpath(File.expand_path('../../../lib', __FILE__))
+$LOAD_PATH.unshift '/srv/whimsy/lib'
 require 'tmpdir'
 require 'json'
 require 'time'
diff --git a/www/committers/subscribe.cgi b/www/committers/subscribe.cgi
index d736215..a41cc7e 100755
--- a/www/committers/subscribe.cgi
+++ b/www/committers/subscribe.cgi
@@ -1,6 +1,6 @@
 #!/usr/bin/env ruby
 PAGETITLE = "ASF Mailing List Subscription Helper" # Wvisible:mail subscribe
-$LOAD_PATH.unshift File.realpath(File.expand_path('../../../lib', __FILE__))
+$LOAD_PATH.unshift '/srv/whimsy/lib'
 require 'wunderbar'
 require 'wunderbar/bootstrap'
 require 'mail'
@@ -39,6 +39,8 @@
 pmcs = ASF::Committee.pmcs.map(&:mail_list)
 ldap_pmcs = [] # No need to get the info for ASF members
 ldap_pmcs = user.committees.map(&:mail_list) unless user.asf_member?
+# Also allow podling private lists to be subscribed by podling owners
+ldap_pmcs += user.podlings.map(&:mail_list) unless user.asf_member?
 lists = ASF::Mail.cansub(user.asf_member?, ASF.pmc_chairs.include?(user), ldap_pmcs)
 lists -= ASF::Mail.deprecated
 lists -= BLACKLIST
@@ -70,9 +72,9 @@
           _a 'https://id.apache.org/', href: "https://id.apache.org/details/#{$USER}"
           _ 'where you can change your primary Forwarding Address and any other associated Alias email addresses you use.'
         end
+        _p 'ASF members can use this form to subscribe to private lists. PMC chairs can subscribe to board lists. (P)PMC members can subscribe to their private@ list.'
+        _p 'The subscription request will be queued and should be processed within about an hour.'
         _p do
-          _ 'ASF members can use this form to subscribe to private lists. PMC chairs can subscribe to board lists. PMC members can subscribe to their private@ list.'
-          _br
           _ 'To subscribe to other private lists, send an email to the list-subscribe@ address and wait for the request to be manually approved.'
           _ 'This might take a day or two.'
         end
diff --git a/www/committers/svn-info.cgi b/www/committers/svn-info.cgi
index 63afa59..5ba4c40 100755
--- a/www/committers/svn-info.cgi
+++ b/www/committers/svn-info.cgi
@@ -1,6 +1,6 @@
 #!/usr/bin/env ruby
 PAGETITLE = "Subversion Info Helper" # Wvisible:tools svn
-$LOAD_PATH.unshift File.realpath(File.expand_path('../../../lib', __FILE__))
+$LOAD_PATH.unshift '/srv/whimsy/lib'
 require 'wunderbar'
 require 'wunderbar/bootstrap'
 require 'whimsy/asf'
diff --git a/www/committers/testauth.cgi b/www/committers/testauth.cgi
index 04c1c0b..f930cb7 100755
--- a/www/committers/testauth.cgi
+++ b/www/committers/testauth.cgi
@@ -4,7 +4,7 @@
 # Small CGI to help debug board agenda authentication issues
 #
 
-$LOAD_PATH.unshift File.realpath(File.expand_path('../../../lib', __FILE__))
+$LOAD_PATH.unshift '/srv/whimsy/lib'
 require 'wunderbar'
 require 'wunderbar/bootstrap'
 require 'whimsy/asf/rack'
@@ -19,18 +19,19 @@
       '/status/' => 'Whimsy Server Status'
     },
     helpblock: -> {
-      _ 'This script checks your authorization to use the agenda tool, and checks if you are listed as attending the current board meeting in the official agenda.'
+      _ 'This script checks your authorization to use the agenda tool, and checks if you are listed as attending the current board meeting in the upcoming official agenda.'
     }
   ) do
     FOUNDATION_BOARD = ASF::SVN['foundation_board']
-    agenda = Dir[File.join(FOUNDATION_BOARD, 'board_agenda_*.txt')].sort.last.untaint
-    agenda = ASF::Board::Agenda.parse(File.read(agenda))
+    agendafile = Dir[File.join(FOUNDATION_BOARD, 'board_agenda_*.txt')].sort.last.untaint
+    agenda = ASF::Board::Agenda.parse(File.read(agendafile))
     roll = agenda.find {|item| item['title'] == 'Roll Call'}
 
     person = ASF::Auth.decode(env)
+    _p %{ Your data for meeting: #{File.basename(agendafile)} }
     _table do
       _tr do
-        _td 'User id'
+        _td 'Your id'
         _td person.id
       end
 
diff --git a/www/committers/tm-report.cgi b/www/committers/tm-report.cgi
index 4e71d55..c66e19f 100755
--- a/www/committers/tm-report.cgi
+++ b/www/committers/tm-report.cgi
@@ -1,6 +1,6 @@
 #!/usr/bin/env ruby
 PAGETITLE = "Apache Trademark Misuse Reporting Form"
-$LOAD_PATH.unshift File.realpath(File.expand_path('../../../lib', __FILE__))
+$LOAD_PATH.unshift '/srv/whimsy/lib'
 require 'wunderbar'
 require 'wunderbar/bootstrap'
 require 'wunderbar/jquery'
diff --git a/www/committers/tools.cgi b/www/committers/tools.cgi
index a2b9bd2..093f428 100755
--- a/www/committers/tools.cgi
+++ b/www/committers/tools.cgi
@@ -1,7 +1,7 @@
 #!/usr/bin/env ruby
 PAGETITLE = "Listing Of Whimsy Tools" # Wvisible:tools
 
-$LOAD_PATH.unshift File.realpath(File.expand_path('../../../lib', __FILE__))
+$LOAD_PATH.unshift '/srv/whimsy/lib'
 require 'json'
 require 'whimsy/asf'
 require 'wunderbar'
diff --git a/www/docs/hardcoded.cgi b/www/docs/hardcoded.cgi
new file mode 100755
index 0000000..767ecbb
--- /dev/null
+++ b/www/docs/hardcoded.cgi
@@ -0,0 +1,53 @@
+#!/usr/bin/env ruby
+PAGETITLE = "Hardcoded Data In Code" # Wvisible:tools data
+
+$LOAD_PATH.unshift '/srv/whimsy/lib'
+require 'json'
+require 'whimsy/asf'
+require 'wunderbar'
+require 'wunderbar/bootstrap'
+GITWHIMSY = 'https://github.com/apache/whimsy/blob/master/'
+HARDCODED = 'hardcoded.json'
+hclist = JSON.parse(File.read(HARDCODED))
+
+_html do
+  _body? do
+    _whimsy_body(
+      title: PAGETITLE,
+      related: {
+        "https://github.com/apache/whimsy/blob/master/DEVELOPMENT.md" => "Whimsy Dev Environment Setup",
+        "/public" => "Whimsy public JSON datafiles",
+        "/docs" => "Whimsy code/API developer documentation"
+      },
+      helpblock: -> {
+        _p %{ Whimsy tools integrate directly with a wide variety of 
+          private and public data and processes within the ASF.  Many 
+          tools also hardcode lists or mappings of data that is 
+          canonically stored elsewhere.  This is a partial list.
+        }
+        _p %{ Many of these hardcoded lists are good things, and are 
+          in the right part of the code.  Some lists may turn out to 
+          be better stored elsewhere, either in Whimsy or other repos.
+        }
+      }
+    ) do
+      _ul.list_group do
+        hclist.each do |file, info|
+          _li.list_group_item do
+            _a '', name: file.gsub(/[#%\[\]\{\}\\"<>]/, '')
+            _code! do
+              if info['line']
+                _a! file, href: "#{GITWHIMSY}#{file}#L#{info['line']}"
+              else
+                _a! file, href: "#{GITWHIMSY}#{file}"
+              end
+              _span.text_muted " #{info['symbol']}"
+            end
+            _br
+            _ " #{info['description']}"
+          end
+        end
+      end
+    end
+  end
+end
diff --git a/www/docs/hardcoded.json b/www/docs/hardcoded.json
new file mode 100644
index 0000000..9f4ee56
--- /dev/null
+++ b/www/docs/hardcoded.json
@@ -0,0 +1,102 @@
+{
+  "tools/collate_minutes.rb": {
+    "line": "230",
+    "symbol": "",
+    "description": "List of common prefix phrases in resolution titles"
+  },
+  "lib/whimsy/asf/board.rb": {
+    "line": "107",
+    "symbol": "DIRECTOR_MAP",
+    "description": "Map of all director id/initial/names"
+  },
+  "lib/whimsy/asf/committee.rb": {
+    "line": "64",
+    "symbol": "@@aliases.merge!",
+    "description": "Map of PMC display names to shortnames: 'community development' => 'comdev'"
+  },
+  "lib/whimsy/asf/mail.rb": {
+    "line": "86",
+    "symbol": "",
+    "description": "Lists of special cased mailing list names and access"
+  },
+  "lib/whimsy/asf/mlist.rb": {
+    "line": "",
+    "symbol": "",
+    "description": "Various hardcoded logic for details of how lists are archived; some special cases that are related to technical details"
+  },
+  "lib/whimsy/asf/person.rb": {
+    "line": "",
+    "symbol": "",
+    "description": "Various collation bits for people's names; generic to unicode or name prefixes"
+  },
+  "lib/whimsy/asf/podling.rb": {
+    "line": "260",
+    "symbol": "dev_mail_list",
+    "description": "Development mailing list name associated with a given podling"
+  },
+  "lib/whimsy/asf/site.rb": {
+    "line": "",
+    "symbol": "",
+    "description": "Various ASF groups and their website homepage URLs"
+  },
+  "lib/whimsy/asf/agenda/special.rb": {
+    "line": "",
+    "symbol": "",
+    "description": "Various hardcoded strings dealing with board agenda items (historical and current)"
+  },
+  "lib/whimsy/asd/agenda/summary.rb": {
+    "line": "",
+    "symbol": "SKIP_AGENDAS",
+    "description": "defines map of F2F agendas (not traditionally parseable)"
+  },
+  "lib/whimsy/asf/person/override-dates.rb": {
+    "line": "",
+    "symbol": "@@create_date",
+    "description": "Corrected creation dates for accounts created before May 2009"
+  },
+  "tools/check_auth.rb": {
+    "line": "",
+    "symbol": "ROLE_NAMES",
+    "description": "hardcoded list of special non-LDAP names"
+  },
+  "tools/mboxhdr2csv.rb": {
+    "line": "",
+    "symbol": "",
+    "description": "Maps some tool-generated subject lines for different lists"
+  },
+  "tools/ponypoop.rb": {
+    "line": "",
+    "symbol": "",
+    "description": "Mapping some tool-generated subject lines for different lists"
+  },
+  "www/incubator/graduated.cgi": {
+    "line": "9",
+    "symbol": "parents",
+    "description": "List of parent PMCs for some podlings"
+  },
+  "www/members/proxy.cgi": {
+    "line": "",
+    "symbol": "volunteers",
+    "description": "List of volunteers for current member's meeting; update annually per proxies file"
+  },
+  "www/members/subscriptions.cgi": {
+    "line": "",
+    "symbol": "ARCHIVERS",
+    "description": "List of email addreses for private lists"
+  },
+  "www/roster/models/nonpmc.rb": {
+    "line": "10",
+    "symbol": "mail_list",
+    "description": "Map of a few odd mailing list names"
+  },
+  "www/secretary/memapp_check.cgi": {
+    "line": "45",
+    "symbol": "",
+    "description": "List of Member names with special cases for IDs"
+  },
+  "www/test/dataflow.json": {
+    "line": "",
+    "symbol": "",
+    "description": "List of common /public datafiles and explanations"
+  }
+}
diff --git a/www/docs/index.cgi b/www/docs/index.cgi
index f2ea1c1..273d5c7 100755
--- a/www/docs/index.cgi
+++ b/www/docs/index.cgi
@@ -1,7 +1,7 @@
 #!/usr/bin/env ruby
 PAGETITLE = "Apache Whimsy Code Documentation" # Wvisible:docs
 
-$LOAD_PATH.unshift File.realpath(File.expand_path('../../../lib', __FILE__))
+$LOAD_PATH.unshift '/srv/whimsy/lib'
 require 'json'
 require 'whimsy/asf'
 require 'wunderbar'
diff --git a/www/events/other.cgi b/www/events/other.cgi
index 59baceb..063542a 100755
--- a/www/events/other.cgi
+++ b/www/events/other.cgi
@@ -1,7 +1,7 @@
 #!/usr/bin/env ruby
 PAGETITLE = "Other FOSS Conference Listings" # Wvisible:events
 
-$LOAD_PATH.unshift File.realpath(File.expand_path('../../../lib', __FILE__))
+$LOAD_PATH.unshift '/srv/whimsy/lib'
 require 'json'
 require 'net/http'
 require 'whimsy/asf'
diff --git a/www/events/past.cgi b/www/events/past.cgi
index 5a114a2..e3c1a0a 100755
--- a/www/events/past.cgi
+++ b/www/events/past.cgi
@@ -1,7 +1,7 @@
 #!/usr/bin/env ruby
 PAGETITLE = "ApacheCon Historical Listing" # Wvisible:events,apachecon
 
-$LOAD_PATH.unshift File.realpath(File.expand_path('../../../lib', __FILE__))
+$LOAD_PATH.unshift '/srv/whimsy/lib'
 require 'csv'
 require 'json'
 require 'whimsy/asf'
diff --git a/www/events/talks.cgi b/www/events/talks.cgi
index 9899f28..a365bf5 100755
--- a/www/events/talks.cgi
+++ b/www/events/talks.cgi
@@ -1,7 +1,7 @@
 #!/usr/bin/env ruby
 PAGETITLE = "Apache Related Talks Listing" # Wvisible:events
 
-$LOAD_PATH.unshift File.realpath(File.expand_path('../../../lib', __FILE__))
+$LOAD_PATH.unshift '/srv/whimsy/lib'
 require 'json'
 require 'whimsy/asf'
 require 'wunderbar'
diff --git a/www/foundation/orgchart.cgi b/www/foundation/orgchart.cgi
index ef1a3be..199ca26 100755
--- a/www/foundation/orgchart.cgi
+++ b/www/foundation/orgchart.cgi
@@ -1,6 +1,6 @@
 #!/usr/bin/env ruby
 PAGETITLE = "Apache Corporate Organization Chart" # Wvisible:orgchart
-$LOAD_PATH.unshift File.realpath(File.expand_path('../../../lib', __FILE__))
+$LOAD_PATH.unshift '/srv/whimsy/lib'
 require 'json'
 require 'whimsy/asf'
 require 'whimsy/asf/orgchart' # New class not yet in gem; duplicates www/roster/models/orgchart
diff --git a/www/incubator/graduated.cgi b/www/incubator/graduated.cgi
new file mode 100755
index 0000000..7a2a9ba
--- /dev/null
+++ b/www/incubator/graduated.cgi
@@ -0,0 +1,196 @@
+#!/usr/bin/env ruby
+PAGETITLE = "Projects which graduated from the incubator" # Wvisible:incubator
+
+$LOAD_PATH.unshift '/srv/whimsy/lib'
+require 'whimsy/asf'
+require 'wunderbar'
+require 'wunderbar/bootstrap'
+
+parents = {
+  "ant" => "Jakarta",
+  "attic" => "N/A",
+  "community development" => "N/A",
+  "db" => "Jakarta",
+  "incubator" => "N/A",
+  "labs" => "N/A",
+  "logging services" => "Jakarta",
+  "public relations" => "N/A",
+}
+
+source = '/srv/whimsy/www/board/minutes'
+index = File.read("#{source}/index.html")
+
+csection = index[/<h2 id="committee">.*?<h2/m]
+creports = csection.scan(/<a .*?<\/a>/)
+retired = csection.scan(/<del>.*?<\/del>/m)
+
+creports.sort_by! {|committee| committee[/>(.*?)</, 1].downcase}
+zest = creports.find {|committee| committee =~ />Zest</}
+
+
+_html do
+  _body? do
+    _whimsy_body(
+      title: PAGETITLE,
+      related: {
+        "/committers/tools" => "Whimsy Tool Listing",
+        "https://incubator.apache.org/images/incubator_feather_egg_logo_sm.png" => "Incubator Logo, to show that graphics can appear",
+        "https://community.apache.org/" => "Get Community Help",
+        "https://github.com/apache/whimsy/blob/master/www#{ENV['SCRIPT_NAME']}" => "See This Source Code"
+      },
+      helpblock: -> {
+        _p! do
+          _ 'This script cross-checks Committee Reports from '
+          _a 'Board Minutes',
+            href: 'https://whimsy.apache.org/board/minutes/'
+          _ ', '
+          _a 'committee-info.txt',
+            href: 'https://svn.apache.org/repos/private/committers/board/committee-info.txt'
+          _ ',  and '
+          _a 'podlings.xml',
+            href: 'https://svn.apache.org/repos/asf/incubator/public/trunk/content/podlings.xml'
+          _ '.'
+          _p do
+            _ul do
+              _li 'Committee: links to Whimsy summary of board minutes'
+              _li 'Established: date is from ASF meeting minutes; links to the published minutes if found'
+              _li 'Parent PMC; links to the Incubator status page if the PMC represents a graduated podling'
+              _li 'Active?: Listed as an active PMC in committee-info.txt'
+            end
+          end
+        end
+      }
+    ) do
+
+      ASF.init_ldap
+
+      unreported = ASF::Committee.pmcs.map(&:display_name).map(&:downcase)
+      incubated = 0
+
+      #
+      ### Podling mentors vs IPMC
+      #
+      _whimsy_panel_table(
+        title: "Establish Resolutions from Projects that have reported",
+      ) do
+        _table.table.table_hover.table_striped do
+          _thead_ do
+            _tr do
+              _th 'Committee'
+              _th 'Established'
+              _th 'Parent PMC'
+              _th 'Active?'
+            end
+          end
+          _tbody do
+            creports.map do |committee|
+              name = committee[/>(.*?)</, 1]
+              href = committee[/href="(.*?)"/, 1].untaint
+              href = 'Polygene.html' if href == 'Zest.html'
+              page = File.read("#{source}/#{href}").
+                sub(/<footer.*<\/footer>/m, '')
+
+              next if name == 'Zest' # renamed to Polygene
+              next if name == 'Metro' # rejected
+
+              active = unreported.delete(name.downcase)
+
+              graduated = false
+
+              parent = nil
+
+              establish = page.split('<h2').map { |report|
+                title = report[/<h3.*?<\/h3>/]
+                next unless title and 
+                  %w(establish create creation).any? {|word|
+                    title.downcase.include? word
+                  }
+
+                graduated ||= report.downcase.include? 'incubator'
+
+                discharge = report.split(/\n\s*\n/).grep(/discharged/).last
+                if discharge 
+                  parent = discharge[/(\w+)\s*(Project|PMC)/, 1]
+                end
+
+                report[/id="(.*?)"/, 1]
+              }.compact.first
+
+              podling = ASF::Podling.find(name.downcase)
+              incubated += 1 if graduated or podling
+
+              _tr_ do
+                _td do
+                  _a name, href: "../board/minutes/#{href}"
+                end
+                _td do
+                  _a establish, href: "../board/minutes/#{href}##{establish}"
+                end
+                _td do
+                  if podling
+                    _a 'Incubator', href:
+                      "https://incubator.apache.org/projects/#{podling.resource}.html"
+                  else
+                    _span parent || parents[name.downcase]
+                  end
+                end
+                _td !!active
+              end
+            end
+          end
+        end
+      end
+
+      _whimsy_panel_table(
+        title: "Projects that don't have posted reports"
+      ) do
+        _table.table.table_hover.table_striped do
+          _thead_ do
+            _tr do
+              _th 'Committee'
+            end
+          end
+          _tbody do
+            unreported.each do |committee|
+              _tr do
+                _td do
+                  _a committee, href: "../roster/committee/" +
+                    ASF::Committee.find(committee).name
+                end
+              end
+            end
+          end
+        end
+      end unless unreported.empty?
+
+      _whimsy_panel_table(
+        title: "Projects summary"
+      ) do
+        _table.table.table_hover.table_striped id: 'summary' do
+          _tbody do
+            _tr do
+              _td creports.length
+              _td "Committees that have reported"
+            end
+
+            _tr do
+              _td ASF::Committee.pmcs.length
+              _td "Active Committees"
+            end
+
+            _tr do
+              _td retired.length
+              _td "Committees that have retired"
+            end
+
+            _tr do
+              _td incubated
+              _td "Graduated from the incubator"
+            end
+          end
+        end
+      end
+        
+    end
+  end
+end
diff --git a/www/incubator/maillist.cgi b/www/incubator/maillist.cgi
index effe212..922add0 100755
--- a/www/incubator/maillist.cgi
+++ b/www/incubator/maillist.cgi
@@ -1,7 +1,7 @@
 #!/usr/bin/env ruby
 PAGETITLE = "Incubator Podling Mailing Lists" # Wvisible:incubator mail
 
-$LOAD_PATH.unshift File.realpath(File.expand_path('../../../lib', __FILE__))
+$LOAD_PATH.unshift '/srv/whimsy/lib'
 
 require 'whimsy/asf'
 require 'wunderbar/bootstrap'
diff --git a/www/incubator/podling-crosscheck.cgi b/www/incubator/podling-crosscheck.cgi
index 349bf35..853889e 100755
--- a/www/incubator/podling-crosscheck.cgi
+++ b/www/incubator/podling-crosscheck.cgi
@@ -1,7 +1,7 @@
 #!/usr/bin/env ruby
 PAGETITLE = "Incubator/Podling crosscheck" # Wvisible:incubator
 
-$LOAD_PATH.unshift File.realpath(File.expand_path('../../../lib', __FILE__))
+$LOAD_PATH.unshift '/srv/whimsy/lib'
 require 'json'
 require 'whimsy/asf'
 require 'wunderbar'
diff --git a/www/incubator/podlings/by-age.cgi b/www/incubator/podlings/by-age.cgi
index 35ee521..f13d141 100755
--- a/www/incubator/podlings/by-age.cgi
+++ b/www/incubator/podlings/by-age.cgi
@@ -1,6 +1,6 @@
 #!/usr/bin/env ruby
 PAGETITLE = "Incubator Podlings By Age" # Wvisible:incubator historical
-$LOAD_PATH.unshift File.realpath(File.expand_path('../../../../lib', __FILE__))
+$LOAD_PATH.unshift '/srv/whimsy/lib'
 require 'nokogiri'
 require 'date'
 require 'net/http'
@@ -49,7 +49,7 @@
         'https://incubator.apache.org/projects/index.html' => 'List Of Incubator Podlings'
       },
       helpblock: -> {
-        _ 'This shows a sorted list of all Incubator podlings by age since joining.'
+        _ 'This shows a sorted list of all active Incubator podlings by age since joining.'
         # pie chart
         theta = 0
         colors = ['0F0', 'FF0', 'F80', 'F50', 'F00', '800']
@@ -80,7 +80,7 @@
           _ul do
             _li! do
               _ "Count:      #{duration.length} PPMCs ("
-              _a 'history', href: 'https://incubator.apache.org/history/'
+              _a 'history', href: 'https://projects.apache.org/'
               _ ') ('
               _a 'source data', href: 'https://incubator.apache.org/projects/#current'
               _ ')'
diff --git a/www/incubator/signoff.cgi b/www/incubator/signoff.cgi
index b120368..a6ad15e 100755
--- a/www/incubator/signoff.cgi
+++ b/www/incubator/signoff.cgi
@@ -3,7 +3,7 @@
 
 # quick and dirty script to tally up which mentors have been providing
 # signoffs and which have not.
-$LOAD_PATH.unshift File.realpath(File.expand_path('../../../lib', __FILE__))
+$LOAD_PATH.unshift '/srv/whimsy/lib'
 require 'nokogiri'
 require 'wunderbar'
 require 'wunderbar/bootstrap'
@@ -11,7 +11,7 @@
 
 # Authenticate - must be first!
 user = ASF::Person.find($USER)
-incubator = ASF::Committee.find('incubator').members
+incubator = ASF::Committee.find('incubator').owners
 unless user.asf_member? or incubator.include? user
   print "Status: 401 Unauthorized\r\n"
   print "WWW-Authenticate: Basic realm=\"Incubator PMC and Members\"\r\n\r\n"
diff --git a/www/index.html b/www/index.html
index 2538132..1ab3d69 100644
--- a/www/index.html
+++ b/www/index.html
@@ -129,8 +129,21 @@
             </div>
             <div class="panel-body">
               <ul>
-                <li><a href="roster/committer/__self__">Your details</a></li>
-                <li><a href="roster/">Rosters (PMCs, committers, members, groups, podlings)</a></li>
+                <li><a href="roster/committer/__self__">Your personal details</a></li>
+                <li><a href="roster/">Rosters</a>
+                  including:
+                  <a href="roster/committee/">PMCs</a>
+                  |
+                  <a href="roster/nonpmc/">other committees</a>
+                  |
+                  <a href="roster/ppmc/">podlings</a>
+                  |
+                  <a href="roster/group/">groups</a>
+                  |
+                  <a href="roster/members">members</a>
+                  |
+                  <a href="roster/committer/">committers</a>
+                </li>
                 <li><a href="committers/subscribe">Subscribe and unsubscribe from mailing lists</a></li>
                 <li><a href="committers/svn-info">svn info command helper</a></li>
                 <li><a href="committers/moderationhelper">Helper for mailing list moderators (Beta)</a></li>
@@ -169,8 +182,9 @@
                 <li><a href="members/whatif">STV Explorer</a></li>
                 <li><a href="members/proxy">Proxy form (ASF members meeting)</a></li>
                 <li><a href="members/security-subs">Security Mailing List Subscriptions</a></li>
-                <li><a href="members/archivers">CrossCheck of Archiver Subscriptions (issues only)</a></li>
-                <li><a href="members/archivers/all">CrossCheck of Archiver Subscriptions (all entries)</a></li>
+                <li><a href="members/archivers">Archiver Subscription Issues - ignoring missing mail-archive subs</a></li>
+                <li><a href="members/archivers/mail-archive">Archiver Subscription Issues - including missing mail-archive subs</a></li>
+                <li><a href="members/archivers/all">Archiver Subscription Checks (all entries)</a></li>
             </div>
           </div>
         </div>
@@ -184,6 +198,7 @@
                 <li><a href="secretary/workbench/">Secretary Workbench</a></li>
                 <li><a href="secretary/icla-lint">Lint test for iclas.txt</a></li>
                 <li><a href="secretary/ldap-check">LDAP members and owners checks (may take a while to respond)</a></li>
+                <li><a href="secretary/ldap-check-committers">Detailed LDAP missing committer check; shows subs and mods if any (may take a while to respond)</a></li>
                 <li><a href="secretary/memapp_check">Check members.txt against members_apps</a></li>
                 <li><a href="secretary/public-names">Public names: LDAP vs icla.txt</a></li>
                 <li><a href="secretary/ldap-names">LDAP name check: compare cn, sn, givenName</a></li>
@@ -203,7 +218,7 @@
         </div>
         <div class="panel-body">
           <p>
-            Copyright &copy; 2015-2018 The Apache Software Foundation, Licensed under
+            Copyright &copy; 2015-2019 The Apache Software Foundation, Licensed under
             the <a href="http://www.apache.org/licenses/LICENSE-2.0" rel="license">Apache License, Version 2.0</a>.
             | 
             <a href="https://www.apache.org/foundation/policies/privacy">Privacy Policy</a>
diff --git a/www/members/archivers.cgi b/www/members/archivers.cgi
index c36bfe9..e8c8f8f 100755
--- a/www/members/archivers.cgi
+++ b/www/members/archivers.cgi
@@ -11,7 +11,17 @@
 
 ids={}
 binarchives = ASF::Mail.lists(true)
-show_all = (ENV['PATH_INFO'] == '/all')
+binarchtime = ASF::Mail.list_mtime
+
+show_all = (ENV['PATH_INFO'] == '/all') # all entries, regardless of error state
+# default is to show entry if neither mail-archive nor markmail is present (mail-archive is missing from a lot of lists)
+show_mailarchive = (ENV['PATH_INFO'] == '/mail-archive') # show entry if mail-archive is missing
+
+# list of ids deliberately not archived
+#                 INFRA-18129
+NOT_ARCHIVED = %w{apachecon-aceu19}
+
+sublist_time = ASF::MLIST.list_time
 
 _html do
   _body? do
@@ -25,13 +35,44 @@
         _p! do
           _ 'This script compares bin/.archives with the list of archiver addresses that are subscribed to mailing lists'
           _br
-          _ 'Every entry in bin/.archives should have up to 3 archive subscribers, except for the mail aliases, which are not lists.'
+          _ 'Every entry in bin/.archives should have up to 3 archive subscribers (5 for public lists), except for the mail aliases, which are not lists.'
           _br
           _ 'Every mailing list should have an entry in bin/.archives'
           _br
           _ 'Unexpected/missing entries are flagged'
           _br
           _ 'Minotaur emails can be either aliases (tlp-list-archive@tlp.apache.org) or direct (apmail-tlp-list-archive@www.apache.org).'
+          _br
+          _ 'Columns:'
+          _ul do
+            _li 'id - short id of list as used on mod_mbox'
+            _li 'list - full list name'
+            _li "Private? - public/private; derived from bin/.archives as at #{binarchtime}"
+            _li 'MINO - minotaur archiver'
+            _li 'MBOX - mbox-vm archiver'
+            _li 'PONY - PonyMail (lists.apache.org) archiver'
+            _li 'MAIL-ARCHIVE - @mail-archive.com archiver (public lists only)'
+            _li 'MARKMAIL - markmail.org archiver (public lists only)'
+            _li "Archivers - list of known archiver subscriptions as at #{sublist_time}"
+          end
+          _ 'Showing: '
+          unless show_all or show_mailarchive
+            _b 'issues (ignoring missing mail-archive subscriptions)'
+          else
+            _a 'issues (ignoring missing mail-archive subscriptions)', href: './'
+          end
+          _ ' | '
+          if show_mailarchive
+            _b 'issues including missing mail-archive subscriptions'
+          else
+            _a 'issues including missing mail-archive subscriptions', href: './mail-archive'
+          end
+          _ ' | '
+          if show_all
+            _b 'details for all lists'
+          else
+            _a 'details for all lists', href: './all'
+          end
         end
       }
     ) do
@@ -44,50 +85,84 @@
         _th 'MINO'
         _th 'MBOX'
         _th 'PONY'
+        _th 'MAIL-ARCHIVE'
+        _th 'MARKMAIL'
         _th 'Archivers', data_sort: 'string'
       end
       ASF::MLIST.list_archivers do |dom, list, arcs|
 
         id = ASF::Mail.archivelistid(dom, list)
 
+        next if NOT_ARCHIVED.include? id # skip error reports. TODO check if it is archived
+
         ids[id] = 1 # TODO check for duplicates
 
         options = Hash.new # Any fields have warnings/errors?
 
-        pubprv = binarchives[id]
+        pubprv = binarchives[id] # public/private
 
-        mino = arcs.select{|e| e[1] == :MINO}.map{|e| e[2]}.first
-        if mino
+        # in case there are multiple archivers with different classifications, we
+        # join all the unique entries. 
+        # This is equivalent to first if there is only one, but will produce
+        # a string such as 'privatepublic' if there are distinct entries
+        # However it generates an empty string if there are no entries.
+
+        mino = arcs.select{|e| e[1] == :MINO}.map{|e| e[2]}.uniq.join('')
+        if ! mino.empty?
           options[:mino]={class: 'info'} unless mino == 'alias'
         else
           mino = 'Missing'
           options[:mino]={class: 'warning'}
         end 
         
-        mbox = arcs.select{|e| e[1] == :MBOX}.map{|e| e[2]}.first
-        if mbox
+        mbox = arcs.select{|e| e[1] == :MBOX}.map{|e| e[2]}.uniq.join('')
+        if ! mbox.empty?
           options[:mbox] = {class: 'danger'} if pubprv && mbox != pubprv  
         else
           mbox = 'Missing'
           options[:mbox] = {class: 'warning'}
         end
 
-        pony = arcs.select{|e| e[1] == :PONY}.map{|e| e[2]}.first
-        if pony
+        pony = arcs.select{|e| e[1] == :PONY}.map{|e| e[2]}.uniq.join('')
+        if ! pony.empty?
           options[:pony] = {class: 'danger'} if pubprv && pony != pubprv  
         else
           pony = 'Missing'
           options[:pony] = {class: 'warning'}
         end
 
+        mail_archive = arcs.select{|e| e[1] == :MAIL_ARCHIVE}.map{|e| e[2]}.uniq.join('')
+        if ! mail_archive.empty?
+          options[:mail_archive] = {class: 'danger'} if pubprv && mail_archive != pubprv  
+        elsif pubprv == 'private'
+          mail_archive = 'N/A'
+        else
+          mail_archive = 'Missing'
+          options[:mail_archive] = {class: 'warning'}
+        end
           
-        # must be done last
+        markmail = arcs.select{|e| e[1] == :MARKMAIL}.map{|e| e[2]}.uniq.join('')
+        if ! markmail.empty?
+          options[:markmail] = {class: 'danger'} if pubprv && markmail != pubprv  
+        elsif pubprv == 'private'
+          markmail = 'N/A'
+        else
+          markmail = 'Missing'
+          options[:markmail] = {class: 'warning'}
+        end
+              
+        # must be done last as it changes pubprv
         unless pubprv
           pubprv = 'Not listed in bin/.archives'
           options[:pubprv] = {class: 'warning'} 
         end
 
-        next unless show_all || options.keys.length > 0 # only show errors unless want all
+        if show_mailarchive
+          needs_attention = options.keys.length > 0
+        else # don't show missing mail-archive
+          needs_attention = options.reject{|k,v| k == :mail_archive && mail_archive == 'Missing'}.length > 0
+        end
+        next unless show_all || needs_attention # only show errors unless want all
 
         _tr do
           _td id
@@ -101,6 +176,8 @@
           _td mino, options[:mino]
           _td mbox, options[:mbox]
           _td pony, options[:pony]
+          _td mail_archive, options[:mail_archive]
+          _td markmail, options[:markmail]
           _td arcs.map{|e| e.first}.sort
         end
       end
diff --git a/www/members/attendance-xcheck.cgi b/www/members/attendance-xcheck.cgi
index b24ad84..7d8c647 100755
--- a/www/members/attendance-xcheck.cgi
+++ b/www/members/attendance-xcheck.cgi
@@ -1,6 +1,6 @@
 #!/usr/bin/env ruby
 PAGETITLE = "Member's Meeting Attendance Cross-Check" # Wvisible:meeting
-$LOAD_PATH.unshift File.realpath(File.expand_path('../../../lib', __FILE__))
+$LOAD_PATH.unshift '/srv/whimsy/lib'
 
 require 'whimsy/asf'
 require 'wunderbar/bootstrap'
diff --git a/www/members/board-attend.cgi b/www/members/board-attend.cgi
index 03c0263..db22040 100755
--- a/www/members/board-attend.cgi
+++ b/www/members/board-attend.cgi
@@ -1,9 +1,10 @@
 #!/usr/bin/env ruby
 PAGETITLE = "Board Meeting Attendance since 2010" # Wvisible:meeting
-$LOAD_PATH.unshift File.realpath(File.expand_path('../../../lib', __FILE__))
+$LOAD_PATH.unshift '/srv/whimsy/lib'
 
 require 'whimsy/asf'
 require 'whimsy/asf/agenda'
+require 'whimsy/asf/board'
 require 'whimsy/public'
 require 'wunderbar/bootstrap'
 require 'json'
@@ -13,38 +14,6 @@
 IS_DIRECTOR = :director
 APPROVED = 'approved'
 
-# Map director ids->names and ids->initials
-# Only since 2010, once the preapp data in meetings is parseable
-INITIALS = 0
-FIRST_NAME = 1
-DISPLAY_NAME = 2
-DIRECTOR_MAP = {
-  'bdelacretaz' => ['bd', 'Bertrand', 'Bertrand Delacretaz'],
-  'brett' => ['bp', 'Brett', 'Brett Porter'],
-  'brianm' => ['bmc', 'Brian', 'Brian McCallister'],
-  'curcuru' => ['sc', 'Shane', 'Shane Curcuru'],
-  'cutting' => ['dc', 'Doug', 'Doug Cutting'],
-  'dkulp' => ['dk', 'Daniel', 'Daniel Kulp'],
-  'fielding' => ['rf', 'Roy', 'Roy T. Fielding'],
-  'geirm' => ['gmj', 'Geir', 'Geir Magnusson Jr'],
-  'gstein' => ['gs', 'Greg', 'Greg Stein'],
-  'isabel' => ['idf', 'Isabel', 'Isabel Drost-Fromm'],
-  'jerenkrantz' => ['je', 'Justin', 'Justin Erenkrantz'],
-  'jim' => ['jj', 'Jim', 'Jim Jagielski'],
-  'ke4qqq' => ['dn', 'David', 'David Nalley'],
-  'lrosen' => ['lr', 'Larry', 'Lawrence Rosen'],
-  'markt' => ['mt', 'Mark', 'Mark Thomas'],
-  'marvin' => ['mh', 'Marvin', 'Marvin Humphrey'],
-  'mattmann' => ['cm', 'Chris', 'Chris Mattmann'],
-  'noirin' => ['np', 'Noirin', 'Noirin Plunkett'],
-  'psteitz' => ['ps', 'Phil', 'Phil Steitz'],
-  'rbowen' => ['rb', 'Rich', 'Rich Bowen'],
-  'rgardler' => ['rg', 'Ross', 'Ross Gardler'],
-  'rubys' => ['sr', 'Sam', 'Sam Ruby'],
-  'rvs' => ['rs', 'Roman', 'Roman Shaposhnik'],
-  'tdunning' => ['td', 'Ted', 'Ted Dunning']
-}
-
 # Summarize director attendance and preapps at one meeting into dstats
 # @note that ASF::Board::Agenda has mix of symbols and strings for hash keys
 # @return string if error
@@ -73,8 +42,8 @@
     actions = agenda.select{ |v| v.has_key?(:index) && v[:index] == "Action Items" }[0]['actions']
     dstats.each do |id, dirmtg|
       if dirmtg.has_key?(meeting)
-        dirmtg[meeting]['preapps'] = (reports.select {|v| v[APPROVED].include?(DIRECTOR_MAP[id][INITIALS])}.length / numreports).round(3)
-        dirmtg[meeting]['actions'] = actions.select{ |v| v[:owner] == DIRECTOR_MAP[id][FIRST_NAME] }.length
+        dirmtg[meeting]['preapps'] = (reports.select {|v| v[APPROVED].include?(ASF::Board.directorInitials(id))}.length / numreports).round(3)
+        dirmtg[meeting]['actions'] = actions.select{ |v| v[:owner] == ASF::Board.directorFirstName(id) }.length
       end
     end
   rescue StandardError => e
@@ -153,13 +122,13 @@
               totp = 0.0
               tota = 0.0
               data.each do |k,v|
-                totp += v['preapps']
-                tota += v['actions']
+                totp += v['preapps'] if v.has_key?('preapps')
+                tota += v['actions'] if v.has_key?('actions')
               end
               _tr_ do
                 _td do
-                  if DIRECTOR_MAP[id] and DIRECTOR_MAP[id][DISPLAY_NAME]
-                    _ DIRECTOR_MAP[id][DISPLAY_NAME]
+                  if ASF::Board.directorHasId?(id) and ASF::Board.directorDisplayName(id)
+                    _ ASF::Board.directorDisplayName(id)
                   else
                     _em.bg_danger id
                   end
diff --git a/www/members/inactive.cgi b/www/members/inactive.cgi
index 3ba7980..c262f67 100755
--- a/www/members/inactive.cgi
+++ b/www/members/inactive.cgi
@@ -1,5 +1,5 @@
 #!/usr/bin/env ruby
-$LOAD_PATH.unshift File.realpath(File.expand_path('../../../lib', __FILE__))
+$LOAD_PATH.unshift '/srv/whimsy/lib'
 
 require 'whimsy/asf'
 require 'wunderbar/bootstrap'
@@ -24,7 +24,8 @@
     }    
   end
   _body? do
-  _whimsy_header 'Poll of Inactive Members'
+  _whimsy_nav
+  _h1 'Poll of Inactive Members'
   # locate and read the attendance file
   MEETINGS = ASF::SVN['Meetings']
   attendance = JSON.parse(IO.read(File.join(MEETINGS, 'attendance.json')))
diff --git a/www/members/index.cgi b/www/members/index.cgi
index 728d197..e42bb5d 100755
--- a/www/members/index.cgi
+++ b/www/members/index.cgi
@@ -1,7 +1,7 @@
 #!/usr/bin/env ruby
 PAGETITLE = "Overview of Whimsy Tools for Members" # Wvisible:meeting
 
-$LOAD_PATH.unshift File.realpath(File.expand_path('../../../lib', __FILE__))
+$LOAD_PATH.unshift '/srv/whimsy/lib'
 require 'json'
 require 'whimsy/asf'
 require 'wunderbar'
diff --git a/www/members/logs.cgi b/www/members/logs.cgi
index 748ec34..258dd95 100755
--- a/www/members/logs.cgi
+++ b/www/members/logs.cgi
@@ -1,6 +1,6 @@
 #!/usr/bin/env ruby
 PAGETITLE = "Server error log listing" # Wvisible:debug
-$LOAD_PATH.unshift File.realpath(File.expand_path('../../../lib', __FILE__))
+$LOAD_PATH.unshift '/srv/whimsy/lib'
 require 'json'
 require 'whimsy/asf'
 require 'wunderbar'
@@ -27,7 +27,7 @@
         _tbody do
           logs.each do | key, val |
             _tr_ do
-              _td do
+              _td :class => 'nowrap' do
                 _ key
               end
               _td do
@@ -60,31 +60,36 @@
 # Emit table of interesting access logs (optional, with ?access)
 def display_access()
   apps, misses = LogParser.get_access_reports()
-  
   _p do
-    _ 'This only includes a small subset of possibly interesting access log entries, roughly categorized by major application (board, roster, etc.)'
+    _ 'This only includes a subset of possibly interesting access log entries from the current day, roughly categorized by major application (board, roster, etc.)'
     _a 'See the full server logs directory.', href: '/members/log'
   end 
-  _h2 'Access Log Synopsis - by Application'
-  apps.each do |name, data|
-    _h3 "#{name} - application"
-    _table.table.table_hover.table_striped do
-      _thead_ do
-        _tr do
-          _th 'User list'
-          _th 'URLs hit (total)'
-        end
-        _tbody do
-          _tr_ do
-            _td do
-              data['remote_user'].each do |remote_user|
-                _ remote_user
-              end
+  _h2 'Access Log Synopsis - by Path or Tool'
+  listid = 'applist'
+  _div.panel_group id: listid, role: 'tablist', aria_multiselectable: 'true' do
+    apps.each_with_index do |(name, data), n|
+      itemtitle = LogParser::WHIMSY_APPS[name] ? LogParser::WHIMSY_APPS[name] : 'All Other URLs'
+      itemtitle << " (#{data['remote_user'].sum{|k,v| v}})" if data['remote_user']
+      _whimsy_accordion_item(listid: listid, itemid: name, itemtitle: "#{itemtitle}", n: n, itemclass: 'panel-info') do
+        _table.table.table_hover.table_striped do
+          _thead_ do
+            _tr do
+              _th 'User list'
+              _th 'URLs hit (total)'
             end
-            _td do
-              data['uri'].sort.each do |uri|
-                _ uri
-                _br
+            _tbody do
+              _tr_ do
+                _td do
+                  data['remote_user'].each do |remote_user|
+                    _ remote_user
+                  end
+                end
+                _td do
+                  data['uri'].sort.each do |uri|
+                    _ uri
+                    _br
+                  end
+                end
               end
             end
           end
@@ -92,8 +97,8 @@
       end
     end
   end
-  _whimsy_panel(title: 'Access Log Synopsis - Error URLs') do
-    _p 'This is a simplistic listing of all URLs with 4xx/5xx error codes (excluding obvious bots).'
+  _whimsy_panel('Access Log Synopsis - Error URLs', style: 'panel-warning') do
+    _p 'This is a simplistic listing of all URLs with 4xx/5xx error codes (excluding obvious bot URLs).'
     erruri = {}
     errref = {}
     misses.each do |h|
@@ -116,6 +121,11 @@
 end
 
 _html do
+  _style %{
+    .nowrap {
+      white-space: nowrap;
+    }
+  }
   _body? do
     _whimsy_body(
       title: PAGETITLE,
@@ -124,10 +134,17 @@
       related: {
         '/members/log' => 'Full server error and access logs',
         '/docs' => 'Whimsy code and API documentation',
+        '/status' => 'Whimsy production server status',
         "https://github.com/apache/whimsy/blob/master/www#{ENV['SCRIPT_NAME']}" => 'See This Source Code'
       },
       helpblock: -> {
-        _p 'This parses error.log and whimsy_error.log and displays a condensed version, in time order (approximate).'
+        _p 'This parses error.log and whimsy_error.log and displays a condensed version, in time order (approximate) of today\'s entries.'
+        _p do
+          _a 'Append "?week"', href: "#{ENV['SCRIPT_NAME']}?week"
+          _ ' to the URL to get error results for the last week, and '
+          _a 'append "?access"', href: "#{ENV['SCRIPT_NAME']}?access"
+          _ ' to parse the access logs instead.'
+        end
         _p do
           _span.text_warning 'Reminder: '
           _span.glyphicon.glyphicon_lock :aria_hidden
diff --git a/www/members/memberless-pmcs.cgi b/www/members/memberless-pmcs.cgi
index 61fd72f..ca449b9 100755
--- a/www/members/memberless-pmcs.cgi
+++ b/www/members/memberless-pmcs.cgi
@@ -1,6 +1,6 @@
 #!/usr/bin/env ruby
 PAGETITLE = "Crosscheck PMCs with few/no ASF Members" # Wvisible:members
-$LOAD_PATH.unshift File.realpath(File.expand_path('../../../lib', __FILE__))
+$LOAD_PATH.unshift '/srv/whimsy/lib'
 require 'whimsy/asf'
 require 'wunderbar'
 require 'wunderbar/bootstrap'
diff --git a/www/members/mentor-format.rb b/www/members/mentor-format.rb
new file mode 100644
index 0000000..924a8ec
--- /dev/null
+++ b/www/members/mentor-format.rb
@@ -0,0 +1,72 @@
+# Utility methods and structs related to mentor data
+require 'json'
+require 'tzinfo'
+
+class MentorFormat
+  ROSTER = 'https://whimsy.apache.org/roster/committer/'
+  MENTORS_SVN = 'https://svn.apache.org/repos/private/foundation/mentors/'
+  MENTORS_LIST = 'https://whimsy.apache.org/member/mentors.cgi'
+  PUBLICNAME = 'publicname'
+  NOTAVAILABLE = 'notavailable'
+  ERRORS = 'errors'
+  TIMEZONE = 'timezone'
+  TZ = TZInfo::Timezone.all_country_zone_identifiers
+  PREFERS_TYPES = [
+    'email',
+    'phone',
+    'Slack',
+    'irc',
+    'Hangouts',
+    'Facebook',
+    'Skype',
+    'other (text chat)',
+    'other (video chat)'
+  ]
+  LANGUAGES = [ # Wikipedia top list by total speakers, plus EU
+    'Arabic',
+    'Bengali',
+    'Bulgarian',
+    'Chinese',
+    'Croatian',
+    'Czech',
+    'Danish',
+    'Dutch',
+    'English',
+    'Estonian',
+    'Finnish',
+    'French',
+    'German',
+    'Greek',
+    'Hindi',
+    'Hungarian',
+    'Indonesean',
+    'Irish',
+    'Italian',
+    'Japanese',
+    'Korean',
+    'Latvian',
+    'Lithuanian',
+    'Maltese',
+    'Marathi',
+    'Polish',
+    'Portugese',
+    'Punjabi',
+    'Romanian',
+    'Russian',
+    'Slovak',
+    'Slovene',
+    'Spanish',
+    'Swahili',
+    'Swedish',
+    'Tamil',
+    'Telugu',
+    'Thai',
+    'Turkish',
+    'Vietnamese'
+  ]
+
+  # Read mapping of labels to fields
+  def self.get_uimap(path)
+    return JSON.parse(File.read(File.join(path, 'ui-map.json')))
+  end
+end
\ No newline at end of file
diff --git a/www/members/mentor-update.cgi b/www/members/mentor-update.cgi
new file mode 100755
index 0000000..25437fe
--- /dev/null
+++ b/www/members/mentor-update.cgi
@@ -0,0 +1,259 @@
+#!/usr/bin/env ruby
+PAGETITLE = "Create/Update Your Mentor Record" # Wvisible:members
+$LOAD_PATH.unshift '/srv/whimsy/lib'
+
+require 'wunderbar'
+require 'wunderbar/bootstrap'
+require 'wunderbar/jquery'
+require 'whimsy/asf'
+require 'wunderbar/markdown'
+require 'whimsy/asf/rack'
+require 'json'
+require 'tzinfo'
+require_relative 'mentor-format'
+require 'whimsy/asf/forms'
+
+# Convenience function
+def emit_mentor_input(field, mdata, uimap, icon, req: false)
+  _whimsy_forms_input(label: uimap[field][0], name: field, required: req, 
+    icon: icon, value: (mdata[field] ? mdata[field] : ''),
+    helptext: uimap[field][1]
+  )
+end
+
+# Display the form for user's mentor record (custom function to mentor data structure)
+def emit_form(apacheid, mdata, button_help, uimap)
+  title = mdata.empty?() ? 'Volunteer to Mentor a New ASF Member' : 'Update your Mentor Record'
+  _whimsy_panel("#{title} (#{apacheid})", style: 'panel-success') do
+    _form.form_horizontal method: 'post' do
+      if mdata.has_key?(MentorFormat::ERRORS)
+        _div.alert.alert_danger role: 'alert' do
+          _p 'There was an error parsing the .json; you might need to manually edit it instead:'
+          _p.text_error mdata[MentorFormat::ERRORS]
+        end
+      end
+      
+      _div.form_group do
+        _label.col_sm_offset_3.col_sm_9.strong.text_left 'How Mentees Should Work With You'
+      end
+      emit_mentor_input('contact', mdata, uimap, 'glyphicon-bullhorn', req: true)
+      field = 'timezone'
+      _whimsy_forms_select(label: uimap[field][0], name: field, 
+        values: (mdata[field] ? mdata[field] : ''),
+        options: MentorFormat::TZ.sort,
+        icon: 'glyphicon-time', iconlabel: 'clock', 
+        helptext: uimap[field][1]
+      )
+      emit_mentor_input('availability', mdata, uimap, 'glyphicon-hourglass')
+      field = 'prefers'
+      _whimsy_forms_select(label: uimap[field][0], name: field, multiple: true, 
+        values: (mdata[field] ? mdata[field] : ''),
+        options: MentorFormat::PREFERS_TYPES,
+        icon: 'glyphicon-ok-sign', iconlabel: 'ok-sign', 
+        helptext: uimap[field][1]
+      )
+      field = 'languages'
+      _whimsy_forms_select(label: uimap[field][0], name: field, multiple: true, 
+        values: (mdata[field] ? mdata[field] : ''),
+        options: MentorFormat::LANGUAGES,
+        icon: 'glyphicon-globe', iconlabel: 'globe', 
+        helptext: uimap[field][1]
+      )
+      
+      _div.form_group do
+        _label.col_sm_offset_3.col_sm_9.strong.text_left 'What You Could Help Mentees With'
+      end
+      emit_mentor_input('experience', mdata, uimap, 'glyphicon-certificate')
+      emit_mentor_input('available', mdata, uimap, 'glyphicon-plus-sign')
+      emit_mentor_input('mentoring', mdata, uimap, 'glyphicon-minus-sign')
+      
+      _div.form_group do
+        _label.col_sm_offset_3.col_sm_9.strong.text_left 'More About You Personally'
+      end
+      emit_mentor_input('homepage', mdata, uimap, 'glyphicon-console')
+      emit_mentor_input('pronouns', mdata, uimap, 'glyphicon-user')
+      field = 'aboutme'
+      _whimsy_forms_input(label: uimap[field][0], name: field, rows: 3, 
+        icon: 'glyphicon-info-sign', value: (mdata[field] ? mdata[field] : ''),
+        helptext: uimap[field][1]
+      )
+      
+      _div.form_group do
+        _label.col_sm_offset_3.col_sm_9.strong.text_left 'Temporarily Opt Out From Any NEW Mentees'
+        _label.control_label.col_sm_3 'Not Accepting New Mentees', for: "#{MentorFormat::NOTAVAILABLE}"
+        _div.col_sm_9 do
+          _div.input_group do
+            _label "#{MentorFormat::NOTAVAILABLE}" do
+              args = { type: 'checkbox', id: "#{MentorFormat::NOTAVAILABLE}", name: "#{MentorFormat::NOTAVAILABLE}", value: "#{MentorFormat::NOTAVAILABLE}" }
+              args[:checked] = true if mdata[MentorFormat::NOTAVAILABLE]
+              _input ' Stop accepting NEW Mentees', args
+            end
+          end
+          _span.help_block do
+            _ "Select checkbox to no longer be listed in active mentor list (you can still work with existing Mentees)."
+          end
+        end
+      end
+      
+      _div.col_sm_offset_3.col_sm_9 do
+        _span.text_info button_help
+        _br
+        _input.btn.btn_default type: 'submit', value: 'Update Your Mentor Data'
+      end
+    end
+  end
+end
+
+# Validation as needed within the script
+def validate_form(formdata: {})
+  # Scrub one key if it's blank (only leave it if set to a value)
+  if formdata.has_key?(MentorFormat::NOTAVAILABLE) && formdata[MentorFormat::NOTAVAILABLE] == ''
+    formdata.delete(MentorFormat::NOTAVAILABLE)
+  end
+  return true # TODO: Futureuse
+end
+
+# Handle submission (checkout user's apacheid.json, write form data, checkin file)
+# @return true if we think it succeeded; false in all other cases
+def send_form(formdata: {})
+  rc = 999
+  fn = "#{$USER}.json".untaint
+  mentor_update = JSON.pretty_generate(formdata) + "\n"
+  _div.well do
+    _p.lead "Updating your mentor record #{fn} to be:"
+    _pre mentor_update
+  end
+  
+  Dir.mktmpdir do |tmpdir|
+    credentials = ['--username', $USER, '--password', $PASSWORD]
+    svnopts = ['--no-auth-cache', '--non-interactive']
+    # TODO: investigate if we should to --depth empty and attempt to get only that mentor's file
+    _.system ['svn', 'checkout', MentorFormat::MENTORS_SVN, tmpdir.untaint, svnopts, credentials]
+
+    Dir.chdir tmpdir do
+      if File.exist? fn
+        File.write(fn, mentor_update + "\n")
+        _.system ['svn', 'st']
+        message = "Updating my mentoring data (whimsy)"
+      else
+        File.write(fn, mentor_update + "\n")
+        _.system ['svn', 'add', fn]
+        message = "#{$USER} += mentoring volunteer (whimsy)"
+      end
+      rc = _.system ['svn', 'commit', fn, '--message', message, svnopts, credentials]
+    end
+  end
+  
+  if rc == 0
+    _div.alert.alert_success role: 'alert' do
+      _p do
+        _span.strong 'Your mentor update was submitted, and will be live within a few minutes.  Thanks for volunteering!'
+      end 
+    end
+    return true
+  else
+    _div.alert.alert_danger role: 'alert' do
+      _p do
+        _span.strong 'SVN Update Failed, see above for details; contact dev@whimsical.apache.org for help.  Alternately, edit your Mentor file directly in SVN: '
+        _a "#{MentorFormat::MENTORS_SVN}#{$USER}.json", href: "#{MentorFormat::MENTORS_SVN}#{$USER}.json"
+      end
+    end
+    return false
+  end
+end
+
+# Read user's *.json from directory of mentor files
+# @return user's current mentor data, or {} if none, or sets:
+# myrecord[ERRORS] = "If any error occoured on read/parse"
+def read_myrecord(id)
+  file = File.join(ASF::SVN['foundation_mentors'], "#{id}.json").untaint
+  if File.exist?(file)
+    begin
+      return JSON.parse(File.read(file))
+    rescue StandardError => e
+      return { MentorFormat::ERRORS => "ERROR:read_myrecord(#{file}) #{e.message} #{e.backtrace[0]}" }
+    end
+  else
+    return {}
+  end
+end
+
+# produce HTML
+_html do
+  _style :system
+  _style %{
+    .transcript {margin: 0 16px}
+    .transcript pre {border: none; line-height: 0}
+  }
+  _body? do
+    myrecord = read_myrecord($USER)
+    intro = "You can use this form to update your existing Mentor record, which will be checked into #{MentorFormat::MENTORS_SVN}"
+    header = 'Update Your Mentor Data (most fields optional)'
+    button_help = "Pressing Update will update your existing Mentoring Record in #{MentorFormat::MENTORS_SVN}#{$USER}.json"
+    if myrecord.empty?
+      intro = "You can use this form to volunteer to Mentor other new ASF Members; when you submit your Mentoring Record will be checked into #{MentorFormat::MENTORS_SVN})"
+      header = 'Enter Your Mentor Data (most fields optional)'
+      button_help = "Pressing Update will checkin your Mentoring Record into #{MentorFormat::MENTORS_SVN}#{$USER}.json and list you as a volunteer mentor here: #{MentorFormat::MENTORS_LIST}##{$USER}"
+    elsif myrecord.has_key?(MentorFormat::ERRORS)
+      intro = "Your existing .json file has an error (see below), please work with the Whimsy PMC to fix it: #{MentorFormat::MENTORS_SVN}#{$USER}.json"
+      header = 'There was an error either finding or JSON parsing your mentor record!'
+      button_help = "ERROR: We couldn't properly parse your existing .json file, this form may not work properly."
+    end
+    uimap = MentorFormat.get_uimap(ASF::SVN['foundation_mentors'])
+    _whimsy_body(
+      title: PAGETITLE,
+      subtitle: header,
+      related: {
+        MentorFormat::MENTORS_SVN => 'See All Mentors Data',
+        '/members/mentors' => 'List Of Active Mentors',
+        '/members/index/' => 'Other Member-Private Tools',
+        'https://community.apache.org/' => 'Apache Community Development'
+      },
+      helpblock: -> {
+        _p intro
+        _p.text_warning 'Reminder: All Mentoring data is private to the ASF; only ASF Members can sign up here as Mentors or Mentees.'
+      }
+    ) do
+
+      # Display data to the user, depending if we're GET (existing mentor record or just blank data) or POST (show SVN checkin results)
+      if _.post?
+        submission = {
+          "timezone" => "#{@timezone}",
+          "availability" => "#{@availability}",
+          "contact" => "#{@contact}",
+          "available" => "#{@available}",
+          "mentoring" => "#{@mentoring}",
+          "experience" => "#{@experience}",
+          "pronouns" => "#{@pronouns}",
+          "aboutme" => "#{@aboutme}",
+          "homepage" => "#{@homepage}",
+          # Multiple select fields
+          "prefers" => _.params['prefers'],
+          "languages" => _.params['languages']
+        }
+        if @notavailable
+          submission['notavailable'] = "#{@notavailable}"
+        end
+        if validate_form(formdata: submission)
+          if send_form(formdata: submission)
+            _p.lead "Thanks for volunteering to mentor other ASF Members!"
+            _p do 
+              _ "Your record will now show up on the list of active mentors (unless you had checked 'notavailable'). "
+              _a 'See the current list of active mentors', href: '/members/mentors'
+            end
+          end
+        else
+          _div.alert.alert_danger role: 'alert' do
+            _p do
+              _span.strong "WARNING: Form data invalid, update was NOT submitted! "
+              _br
+              _ "There was a validation error with your form submission; please contact dev@whimsical.apache.org with a bug report."
+            end
+          end
+        end
+      else # if _.post?
+        emit_form($USER, myrecord, button_help, uimap)
+      end
+    end
+  end
+end
diff --git a/www/members/mentors.cgi b/www/members/mentors.cgi
new file mode 100755
index 0000000..9e1fd86
--- /dev/null
+++ b/www/members/mentors.cgi
@@ -0,0 +1,154 @@
+#!/usr/bin/env ruby
+PAGETITLE = "Available Mentors For New Members" # Wvisible:members
+$LOAD_PATH.unshift '/srv/whimsy/lib'
+
+require 'whimsy/asf'
+require 'wunderbar/bootstrap'
+require 'wunderbar/markdown'
+require 'json'
+
+require_relative 'mentor-format'
+MENTORS_LIST = 'mentors'
+
+# Read apacheid.json and add data to mentors hash (side effect)
+# mentors[id][ERRORS] = "If errors rescued during read/find in ASF::Person"
+def read_mentor(file, mentors)
+  id = File.basename(file).split('.')[0]
+  member = ASF::Person[id] # We want to return nil if id not found
+  if member
+    begin
+      mentors[id] = JSON.parse(File.read(file))
+      mentors[id][MentorFormat::PUBLICNAME] = member.public_name()
+    rescue StandardError => e
+      mentors[id] = { MentorFormat::ERRORS => "ERROR:read_mentor() #{e.message} #{e.backtrace[0]} from #{file}"}
+    end
+  else
+    mentors[id] = { MentorFormat::ERRORS => "ERROR:ASF::Person.find(#{id}) returned nil from #{file}"}
+  end
+end
+
+# Read *.json from directory of mentor files
+# @return hash of mentors by apacheid
+def read_mentors(path)
+  mentors = {}
+  Dir[File.join(path, '*.json')].sort.each do |file|
+    # Skip files with - dashes, they aren't apacheids
+    next if file.include?('-')
+    read_mentor(file.untaint, mentors)
+  end
+  return mentors
+end
+
+# produce HTML
+_html do
+  _body? do
+    uimap = MentorFormat::get_uimap(ASF::SVN['foundation_mentors'])
+    mentors = read_mentors(ASF::SVN['foundation_mentors'])
+    errors, mentors = mentors.partition{ |k,v| v.has_key?(MentorFormat::ERRORS)}.map(&:to_h)
+    notavailable, mentors = mentors.partition{ |k,v| v.has_key?(MentorFormat::NOTAVAILABLE)}.map(&:to_h)
+    _whimsy_body(
+      title: PAGETITLE,
+      subtitle: 'About This Mentoring Program',
+      relatedtitle: 'Other ASF Mentoring Links',
+      related: {
+        MentorFormat::MENTORS_SVN => 'See Raw Mentors Data',
+        '/roster/members' => 'Whimsy All Members Roster',
+        '/members/index/' => 'Other Member-Private Tools',
+        'https://community.apache.org' => 'Apache Community Development'
+      },
+      helpblock: -> {
+        _p do
+          _ 'This page lists experienced ASF Members who have volunteered to mentor newer ASF Members to help them get more involved in governance and operations within the larger Foundation as a whole.'
+        end
+        _p do
+          _ "If you are a newer Member looking for a mentor, please reach out directly to available volunteers below that fit your interests by #{uimap['contact'][0]} and request mentoring.  Not every mentoring pair may be the right fit, so you'll need to decide together if you're a good pair."
+          _ 'Remember, this is an informal program run by volunteers, so please be kind - and patient!   Mentors currently listed as available for new mentees:'
+        end
+        _table do
+          _tr do
+            _td do
+              _a.btn.btn_default.btn_sm (mentors.has_key?($USER) ? 'Edit Your Mentor Record' : 'Volunteer To Mentor'), href: "/members/mentor-update.cgi", role: "button"
+            end
+            _td do
+              _{"&nbsp;"*2}
+            end
+            _td do
+              _ul.list_inline do
+                mentors.each do | apacheid, mentor |
+                  _li do
+                    _a apacheid, href: "##{apacheid}"
+                  end
+                end
+              end
+            end
+          end
+        end
+        _p.text_warning 'Reminder: All Mentoring data is private to the ASF; only ASF Members can sign up here as Mentors or Mentees.'
+      }
+    ) do
+      _div.panel_group id: MENTORS_LIST, role: "tablist", aria_multiselectable: "true" do
+        mentors.each_with_index do |(apacheid, mentor), n| # TODO Should we randomize the default listing?
+          timezone = mentor[MentorFormat::TIMEZONE]
+          offset = TZInfo::Timezone.get(timezone).strftime("%:z")
+          _whimsy_accordion_item(listid: MENTORS_LIST, itemid: apacheid, itemtitle: "#{mentor[MentorFormat::PUBLICNAME]}  (#{apacheid})  Timezone: #{timezone} (#{offset})  ", n: n, itemclass: 'panel-primary') do
+            _table.table.table_hover do
+              _tbody do
+                mentor.delete(MentorFormat::PUBLICNAME) # So not re-displayed again
+                mentor.each do |k, v|
+                  _tr do
+                    _td!.text_right do
+                      _span.text_primary uimap.has_key?(k) ? "#{uimap[k][0]}" : "#{k}"
+                    end
+                    _td!.text_left do
+                      v = v.join(', ') if v.kind_of?(Array)
+                      _markdown v
+                    end
+                  end
+                end
+                _tr do
+                  _td!.text_right do
+                    _ 'ASF Projects/Podlings Involved In'
+                  end
+                  _td!.text_left do
+                    # TODO: instead of link to roster, this could read and display here
+                    _a "#{MentorFormat::ROSTER}#{apacheid}", href: "#{MentorFormat::ROSTER}#{apacheid}"
+                  end
+                end
+              end
+            end
+          end
+        end
+      end
+
+      if not notavailable.empty?
+        _div id: MentorFormat::NOTAVAILABLE do
+          _p! do
+            _! 'Volunteer mentors who are '
+            _strong! 'not'
+            _! ' currently available for new mentees: '
+            notavailable.each do |apacheid, n |
+              _ "#{n[MentorFormat::PUBLICNAME]}, "
+            end
+          end
+        end
+      end
+
+      if not errors.empty?
+        _div id: MentorFormat::ERRORS do
+          _whimsy_panel("Mentor JSON Files With Errors", style: 'panel-danger') do
+            _ul do
+              errors.each do |apacheid, error |
+                _li do
+                  _code "#{apacheid}.json"
+                  _ "#{error[MentorFormat::ERRORS]}"
+                end
+              end
+            end
+            _p 'Please work with dev@whimsical to fix these JSON files.'
+          end
+        end
+      end
+
+    end
+  end
+end
\ No newline at end of file
diff --git a/www/members/mirror_check.cgi b/www/members/mirror_check.cgi
index 5a96a1f..ff3ee1c 100755
--- a/www/members/mirror_check.cgi
+++ b/www/members/mirror_check.cgi
@@ -1,6 +1,6 @@
 #!/usr/bin/env ruby
 PAGETITLE = "ASF Distribution Mirror Checker" # Wvisible:infra mirror
-$LOAD_PATH.unshift File.realpath(File.expand_path('../../../lib', __FILE__))
+$LOAD_PATH.unshift '/srv/whimsy/lib'
 require 'wunderbar'
 require 'wunderbar/bootstrap'
 require 'whimsy/asf'
diff --git a/www/members/namediff.cgi b/www/members/namediff.cgi
index fa5598b..6516fe3 100755
--- a/www/members/namediff.cgi
+++ b/www/members/namediff.cgi
@@ -1,6 +1,6 @@
 #!/usr/bin/env ruby
 PAGETITLE = "Crosscheck Members Names With ICLAs"  # Wvisible:members
-$LOAD_PATH.unshift File.realpath(File.expand_path('../../../lib', __FILE__))
+$LOAD_PATH.unshift '/srv/whimsy/lib'
 
 require 'whimsy/asf'
 require 'wunderbar/bootstrap'
diff --git a/www/members/nominations.cgi b/www/members/nominations.cgi
index 793eaac..8e28f1e 100755
--- a/www/members/nominations.cgi
+++ b/www/members/nominations.cgi
@@ -1,6 +1,6 @@
 #!/usr/bin/env ruby
 PAGETITLE = "Member nominations cross-check" # Wvisible:meeting
-$LOAD_PATH.unshift File.realpath(File.expand_path('../../../lib', __FILE__))
+$LOAD_PATH.unshift '/srv/whimsy/lib'
 
 require 'mail'
 require 'wunderbar/bootstrap'
diff --git a/www/members/non-participants.cgi b/www/members/non-participants.cgi
index 2979b3e..6930939 100755
--- a/www/members/non-participants.cgi
+++ b/www/members/non-participants.cgi
@@ -1,6 +1,6 @@
 #!/usr/bin/env ruby
 PAGETITLE = "Active Members not participating in meetings" # Wvisible:meeting
-$LOAD_PATH.unshift File.realpath(File.expand_path('../../../lib', __FILE__))
+$LOAD_PATH.unshift '/srv/whimsy/lib'
 
 require 'whimsy/asf'
 require 'wunderbar/bootstrap'
diff --git a/www/members/proxy.cgi b/www/members/proxy.cgi
index 7b76725..f2ae90c 100755
--- a/www/members/proxy.cgi
+++ b/www/members/proxy.cgi
@@ -1,5 +1,5 @@
 #!/usr/bin/env ruby
-$LOAD_PATH.unshift File.realpath(File.expand_path('../../../lib', __FILE__))
+$LOAD_PATH.unshift '/srv/whimsy/lib'
 
 require 'wunderbar'
 require 'whimsy/asf'
@@ -8,6 +8,16 @@
 
 MEETINGS = ASF::SVN['Meetings']
 meeting = File.basename(Dir[File.join(MEETINGS, '2*')].sort.last).untaint
+volunteers = [
+  "Phil Steitz (psteitz)",
+  "Shane Curcuru (curcuru)",
+  "Michael Brohl (mbrohl)",
+  "Jim Jagielski (jim)",
+  "Daniel Ruggeri (druggeri)",
+  "Greg Stein (gstein)",
+  "Craig L Russell (clr)",
+  "Bertrand Delacretaz (bdelacretaz)"
+]
 
 # Calculate how many members required to attend first half for quorum
 def calculate_quorum(meeting)
@@ -102,23 +112,28 @@
               _ " Calculation: Total voting members: #{num_members}, with one third for quorum: #{quorum_need}, minus previously submitted proxies: #{num_proxies}"
             end
           end
-          _p %{
-            IMPORTANT! Be sure to tell the person that you select as proxy 
-            that you've assigned them to mark your attendance! They simply 
-            need to mark your proxy attendance when the meeting starts.
-          }
           help, copypasta = is_user_proxied(meeting, $USER)
           if help
             _p help
             if copypasta
-              _ul do
+              _ul.bg_success do
                 copypasta.each do |copyline|
                   _pre copyline
                 end
               end
             end
+          else
+            _p 'The following members have volunteered to serve as proxies; you can freely select any one of them below:'
+            _ul do
+              volunteers.each do |vol|
+                _pre vol
+              end
+            end
           end
-          _a 'Read full procedures for Member Meeting', href: 'https://www.apache.org/foundation/governance/members.html#meetings'
+          _p do
+            _ "IMPORTANT! Be sure to tell the person that you select as proxy that you've assigned them to mark your attendance! They simply need to mark your proxy attendance when the meeting starts."
+            _a 'Read full procedures for Member Meeting', href: 'https://www.apache.org/foundation/governance/members.html#meetings'
+          end
         end
       end
 
@@ -147,7 +162,8 @@
                 next if exclude.include? member.id       # Not attending
                 next unless members_txt[member.id]       # Non-members
                 next if members_txt[member.id]['status'] # Emeritus/Deceased
-                _option member.public_name
+                # Display the availid to users to match volunteers array above
+                _option "#{member.public_name} (#{member.id})"
               end
             end
           end
@@ -187,8 +203,8 @@
       user = ASF::Person.find($USER)
       date = Date.today.strftime("%B %-d, %Y")
 
-      # update proxy form
-      proxy[/authorize _(#{'_' *@proxy.length})/, 1] = @proxy.gsub(' ', '_')
+      # update proxy form (match as many _ as possible up to the name length)
+      proxy[/authorize _(_{,#{@proxy.length}})/, 1] = @proxy.gsub(' ', '_')
 
       proxy[/signature: _(_#{'_' *user.public_name.length}_)/, 1] = 
         "/#{user.public_name.gsub(' ', '_')}/"
@@ -196,6 +212,9 @@
       proxy[/name: _(#{'_' *user.public_name.length})/, 1] = 
         user.public_name.gsub(' ', '_')
 
+      proxy[/availid: _(#{'_' *user.id.length})/, 1] = 
+        user.id.gsub(' ', '_')
+
       proxy[/Date: _(#{'_' *date.length})/, 1] = date.gsub(' ', '_')
 
       proxyform = proxy.untaint
@@ -226,6 +245,8 @@
               id = file[/([-A-Za-z0-9]+)\.\w+$/, 1]
               proxy = form[/hereby authorize ([\S].*) to act/, 1].
                 gsub('_', ' ').strip
+              # Ensure availid is not included in proxy name here
+              proxy = proxy[/([^(]+)/, 1].strip
               name = form[/signature: ([\S].*)/, 1].gsub(/[\/_]/, ' ').strip
 
               "   #{proxy.ljust(24)} #{name} (#{id})"
diff --git a/www/members/repo-use.cgi b/www/members/repo-use.cgi
new file mode 100755
index 0000000..8dd792d
--- /dev/null
+++ b/www/members/repo-use.cgi
@@ -0,0 +1,64 @@
+#!/usr/bin/env ruby
+PAGETITLE = "Scripts that use ASF::SVN" # Wvisible:tools
+$LOAD_PATH.unshift '/srv/whimsy/lib'
+require 'whimsy/asf'
+require 'wunderbar'
+require 'wunderbar/bootstrap'
+require '../../tools/wwwdocs.rb'
+
+_html do
+  _body? do
+    _whimsy_body(
+      title: PAGETITLE,
+      subtitle: 'Scan all scripts for SVN access',
+      relatedtitle: 'More Useful Links',
+      related: {
+        '/members/log' => 'Full server error and access logs',
+        '/docs' => 'Whimsy code and API documentation',
+        '/status' => 'Whimsy production server status',
+        "https://github.com/apache/whimsy/blob/master/www#{ENV['SCRIPT_NAME']}" => 'See This Source Code'
+      },
+      helpblock: -> {
+        _p 'This scans the whimsy repo for uses of ASF::SVN, either public or private repos.'
+      }
+    ) do
+      priv, pub = read_repository(File.expand_path('../../../repository.yml', __FILE__))
+      priv = build_regexp(priv)
+      pub = build_regexp(pub)
+      scan = scan_dir_svn('../../', [priv, pub])
+      _whimsy_panel_table(title: 'Repo use by script') do
+        _table.table.table_hover do
+          _thead_ do
+            _tr do
+              _th 'Private repos used'
+              _th 'Public repos used'
+            end
+            scan.each do |file, (privlines, publines)|
+              _tbody do
+                _tr_ do
+                  _td :colspan => '2' do
+                    _code file
+                  end
+                end
+                _tr do
+                  _td do
+                    privlines.each do |l|
+                      _ l
+                      _br
+                    end
+                  end
+                  _td do
+                    publines.each do |l|
+                      _ l
+                      _br
+                    end
+                  end
+                end
+              end
+            end
+          end
+        end
+      end
+    end
+  end
+end
\ No newline at end of file
diff --git a/www/members/security-subs.cgi b/www/members/security-subs.cgi
index 9d9a241..37cf3a2 100755
--- a/www/members/security-subs.cgi
+++ b/www/members/security-subs.cgi
@@ -1,5 +1,5 @@
 #!/usr/bin/env ruby
-$LOAD_PATH.unshift File.realpath(File.expand_path('../../../lib', __FILE__))
+$LOAD_PATH.unshift '/srv/whimsy/lib'
 require 'wunderbar/bootstrap'
 require 'whimsy/asf'
 require 'whimsy/asf/mlist'
@@ -8,8 +8,17 @@
   /^archive-asf-private@cust-asf\.ponee\.io$/,
   /^private@mbox-vm\.apache\.org$/,
   /^security-archive@.*\.apache\.org$/,
+  /^apmail-\w+-security-archive@www.apache.org/, # Direct subscription
 ]
 
+def isArchiver?(email)
+  WHITELIST.any? {|regex| email =~ regex}
+end
+
+NOSUBSCRIBERS = 'No subscribers'
+MINSUB = 3
+TOOFEW = "Not enough subscribers (< #{MINSUB})"
+
 # ensure that there is a trailing slash (so relative paths will work)
 if not ENV['PATH_INFO']
   print "Status: 302 Found\r\nLocation: #{ENV['SCRIPT_URI']}/\r\n\r\n"
@@ -26,14 +35,47 @@
 
 _html do
   _whimsy_body(
-    title: "Security Mailing List Subscriptions"
+    title: "Security Mailing List Subscriptions",
+    breadcrumbs: {
+      subscriptions: '.'
+    }
+
   ) do
     path = ENV['PATH_INFO'].sub('/', '')
     if path == ''
-      _ul.list_group do
-        lists.each do |dom, subs|
-          _li.list_group_item do
-            _a dom, href: dom
+      _p do
+        _ 'The counts below exclude the archivers, using the highlights: '
+        _span.bg_danger NOSUBSCRIBERS
+        _span.bg_warning TOOFEW
+      end
+      _table.table.table_responsive do
+        _tr do
+          _th.col_xs_1.text_right 'count'
+          _th.col_xs_3 'project'
+          _th.col_xs_1.text_right 'count'
+          _th.col_xs_3 'project'
+          _th.col_xs_1.text_right 'count'
+          _th.col_xs_3 'project'
+          # cols must add up to twelve
+        end
+        lists.each_slice(3) do |slice|
+          _tr do
+            slice.each do |dom, subs|
+              arch = subs.select{|sub| isArchiver?(sub)}.length
+              subcount = (subs.length - arch)
+              options = {}
+              if subcount == 0
+                options = {class: 'bg-danger', title: NOSUBSCRIBERS}
+              elsif subcount < MINSUB
+                options = {class: 'bg-warning', title: TOOFEW}
+              end
+              _td.text_right options do
+                _ subcount
+              end
+              _td do
+                _a dom, href: dom
+              end
+            end
           end
         end
       end
@@ -42,6 +84,71 @@
       podling = ASF::Podling.find(path)
       committee = ASF::Committee.find(path)
       project = ASF::Project.find(path)
+      colors=Hash.new{|h,k| h[k]=0} # counts of colors
+      order=['bg-danger', 'bg-warning', 'bg-info', 'bg-success', ''] # sort order
+      subh = Hash[
+        lists[path].map do |email|
+          name = '*UNKNOWN*'
+          if WHITELIST.any? {|regex| email =~ regex}
+            person = nil
+            name = '(archiver)'
+            color = ''
+          else
+            person = ASF::Person.find_by_email(email)
+            if person
+              name = person.public_name
+              if person.asf_member? or project.owners.include? person
+                color = 'bg-success'
+              elsif project.members.include? person
+                color = 'bg-info'
+              else
+                color = 'bg-warning'
+              end
+            else
+              color = 'bg-danger'
+            end
+          end
+          colors[color] += 1
+          [email, {person: person , color: color, name: name}]
+        end
+      ].sort_by {|k,v| [order.index(v[:color]),v[:name]]}
+      
+      _table do
+        _tr do
+          _th 'Count '
+          _th 'Legend'
+        end
+        _tr do
+          _td colors['bg-danger']
+          _td class: 'bg-danger' do
+            _ 'Person (email) not recognised'
+          end
+        end
+        _tr do
+          _td colors['bg-warning']
+          _td class: 'bg-warning' do
+            _ 'ASF committer not associated with the project'
+          end
+        end
+        _tr do
+          _td colors['bg-info']
+          _td class: 'bg-info' do
+            _ 'Project committer - not on (P)PMC'
+          end
+        end
+        _tr do
+          _td colors['bg-success']
+          _td class: 'bg-success' do
+            _ 'ASF member or project member'
+          end
+        end
+        _tr do
+          _td colors['']
+          _td do
+            _ 'Archiver (there are expected to be up to 3 archivers)'
+          end
+        end
+      end
       _h2 do
         if podling
           _a podling.display_name, 
@@ -50,6 +157,9 @@
           _a committee.display_name, 
             href: "../../roster/committee/#{committee.id}"
         end
+        _span class: 'small' do
+          _a "(security@#{path}.apache.org)", href: "https://lists.apache.org/list.html?security@#{path}.apache.org"
+        end
       end
 
       _table.table do
@@ -61,19 +171,10 @@
         end
 
         _tbody do
-          lists[path].sort_by {|email| email.downcase}.each do |email|
-            person = ASF::Person.find_by_email(email)
-            if person
-              if person.asf_member? or project.members.include? person
-                color = 'bg-success'
-              else
-                color = 'bg-warning'
-              end
-            elsif WHITELIST.any? {|regex| email =~ regex}
-              color = ''
-            else
-              color = 'bg-danger'
-            end
+          subh.each do |email, hash|
+            color = hash[:color]
+            person = hash[:person]
+            name = hash[:name]
 
             _tr class: color do
               _td email
@@ -81,22 +182,24 @@
                 if person
                   if person.asf_member?
                     _b do
-                      _a person.public_name, 
-                        href: "../../roster/committer/#{person.id}"
+                      _a name, href: "../../roster/committer/#{person.id}"
                     end
                   else
-                    _a person.public_name, 
-                      href: "../../roster/committer/#{person.id}"
+                    _a name, href: "../../roster/committer/#{person.id}"
                   end
-                elsif WHITELIST.any? {|regex| email =~ regex}
-                    _ '(archiver)'
+                else
+                    _ name
                 end
               end
             end
           end
         end
       end
-      _p 'Note that there are expected to be upto 3 archivers'
+    else
+      _h3 class: 'bg-warning' do
+        _ "Could not find a security list for the project #{path}"
+      end
+      _br
     end
   end
 end
diff --git a/www/members/subscriptions.cgi b/www/members/subscriptions.cgi
index 0d68e82..e69ae5e 100755
--- a/www/members/subscriptions.cgi
+++ b/www/members/subscriptions.cgi
@@ -1,6 +1,6 @@
 #!/usr/bin/env ruby
 PAGETITLE = "Apache members@ Subscription Crosscheck" # Wvisible:members
-$LOAD_PATH.unshift File.realpath(File.expand_path('../../../lib', __FILE__))
+$LOAD_PATH.unshift '/srv/whimsy/lib'
 
 require 'wunderbar'
 require 'whimsy/asf'
@@ -8,15 +8,7 @@
 require 'wunderbar/bootstrap'
 require 'wunderbar/jquery/stupidtable'
 
-ARCHIVERS = %w(
-  private@mbox-vm.apache.org
-  members-archive@apache.org
-  archive-asf-private@cust-asf.ponee.io
-  members@mmpoc.apache.org
-  members@whimsy-vm4.apache.org
-)
-
-subscribers, modtime = ASF::MLIST.members_subscribers
+subscribers, modtime = ASF::MLIST.members_subscribers(false) # excluding archivers
 
 _html do
   _body? do
@@ -91,7 +83,6 @@
         _th 'name', data_sort: 'string'
       end
       subscriptions.sort.each do |id, person, email|
-        next if ARCHIVERS.include? email
         _tr_ do
           if id.include? '*'
             _td.text_danger id
diff --git a/www/members/watch.cgi b/www/members/watch.cgi
index 1079cb4..e251727 100755
--- a/www/members/watch.cgi
+++ b/www/members/watch.cgi
@@ -1,6 +1,6 @@
 #!/usr/bin/env ruby
 PAGETITLE = "Potential ASF Member Watch List" # Wvisible:members
-$LOAD_PATH.unshift File.realpath(File.expand_path('../../../lib', __FILE__))
+$LOAD_PATH.unshift '/srv/whimsy/lib'
 
 require 'wunderbar'
 require 'whimsy/asf'
@@ -101,9 +101,6 @@
     list = {} # Avoid lint errors of shadowing
     if request =~ /multiple/
       _h2_ 'Active In Multiple Committees'
-#      list = ASF::Committee.list.map {|committee| committee.members}.
-#        reduce(&:+).group_by {|person| person}.
-#        delete_if {|person,list| list.length<3}.keys
       # Use actual PMCs rather than LDAP derived
       list = ASF::Committee.pmcs.map {|pmc| pmc.roster.keys}.
         reduce(&:+).group_by {|uid| uid}.
@@ -135,9 +132,9 @@
 
     # for efficiency, preload public_names, member status, and
     # nominees
-    people = ASF::Person.preload('cn', list)
-    members = ASF::Member.status
-    nominees = ASF::Person.member_nominees
+    ASF::Person.preload('cn', list)
+    ASF::Member.status
+    ASF::Person.member_nominees
 
     _table.table do
 
diff --git a/www/members/whatif.cgi b/www/members/whatif.cgi
index aada25e..c8e6fc9 100755
--- a/www/members/whatif.cgi
+++ b/www/members/whatif.cgi
@@ -1,5 +1,5 @@
 #!/usr/bin/env ruby
-$LOAD_PATH.unshift File.realpath(File.expand_path('../../../lib', __FILE__))
+$LOAD_PATH.unshift '/srv/whimsy/lib'
 
 require 'whimsy/asf/config'
 require 'whimsy/asf/svn'
diff --git a/www/officers/acreq.cgi b/www/officers/acreq.cgi
index 8d6771c..eb356bb 100755
--- a/www/officers/acreq.cgi
+++ b/www/officers/acreq.cgi
@@ -1,6 +1,6 @@
 #!/usr/bin/env ruby
 PAGETITLE = "Apache Account Submission Helper Form" # Wvisible:infra accounts
-$LOAD_PATH.unshift File.realpath(File.expand_path('../../../lib', __FILE__))
+$LOAD_PATH.unshift '/srv/whimsy/lib'
 require 'wunderbar'
 require 'wunderbar/bootstrap'
 require 'wunderbar/jquery'
diff --git a/www/officers/board-stats.cgi b/www/officers/board-stats.cgi
index 5dc9c80..a541fef 100755
--- a/www/officers/board-stats.cgi
+++ b/www/officers/board-stats.cgi
@@ -1,6 +1,6 @@
 #!/usr/bin/env ruby
 PAGETITLE = "Board Meeting Statistics since 2007" # Wvisible:meeting
-$LOAD_PATH.unshift File.realpath(File.expand_path('../../../lib', __FILE__))
+$LOAD_PATH.unshift '/srv/whimsy/lib'
 
 require 'whimsy/asf'
 require 'whimsy/asf/agenda'
diff --git a/www/officers/mlreq.cgi b/www/officers/mlreq.cgi
index b319c7c..07bcb8a 100755
--- a/www/officers/mlreq.cgi
+++ b/www/officers/mlreq.cgi
@@ -1,6 +1,6 @@
 #!/usr/bin/env ruby
 PAGETITLE = "Apache Mailing list Request Form" # Wvisible:infra mail list
-$LOAD_PATH.unshift File.realpath(File.expand_path('../../../lib', __FILE__))
+$LOAD_PATH.unshift '/srv/whimsy/lib'
 require 'wunderbar'
 require 'shellwords'
 require 'mail'
diff --git a/www/pods.cgi b/www/pods.cgi
index 1b9d0a4..6de5f8c 100755
--- a/www/pods.cgi
+++ b/www/pods.cgi
@@ -9,7 +9,7 @@
   exit
 end
 
-$LOAD_PATH.unshift File.realpath(File.expand_path('../../lib', __FILE__))
+$LOAD_PATH.unshift '/srv/whimsy/lib'
 require 'json'
 require 'net/http'
 require 'time' # for httpdate
diff --git a/www/racktest/config.ru b/www/racktest/config.ru
index 373c732..463fbfb 100644
--- a/www/racktest/config.ru
+++ b/www/racktest/config.ru
@@ -1 +1,10 @@
-run lambda {|env| [200, {'Content-Type' => 'text/plain'}, [env.inspect]]}
+require 'json'
+
+run lambda {|env|
+  env = env.to_a.sort.to_h
+  env.delete('PASSENGER_CONNECT_PASSWORD')
+  env.delete('SECRET_KEY_BASE')
+  env.delete('HTTP_AUTHORIZATION')
+  
+  [ 200, {'Content-Type' => 'text/plain'}, [JSON.pretty_generate(env)] ]
+}
diff --git a/www/roster/main.rb b/www/roster/main.rb
index c25bc88..7971489 100755
--- a/www/roster/main.rb
+++ b/www/roster/main.rb
@@ -39,10 +39,11 @@
 
 get '/' do
   if env['REQUEST_URI'].end_with? '/'
+    ASF::Person.preload(['asf-banned','loginShell']) # so can get inactive count
     @committers = ASF::Person.list
     @committees = ASF::Committee.pmcs
     @nonpmcs = ASF::Committee.nonpmcs
-    @members = ASF::Member.list.keys - ASF::Member.status.keys
+    @members = ASF::Member.list.keys - ASF::Member.status.keys # i.e. active member ids
     @groups = Group.list
     @podlings = ASF::Podling.to_h.values
     _html :index
@@ -157,6 +158,24 @@
 end
 
 post '/committer/:userid/:file' do |name, file|
+  # Workround for handling arrays
+  # if the key :array_prefix is defined, the value is assumed to be the prefix for
+  # a list of values with the names: prefix1, prefix2 etc
+  # All non-empty values are collected and stored in an array which is added to the
+  # params with the key prefix
+  prefix = params.delete(:array_prefix)
+  if prefix
+    array = []
+    count = 1
+    loop do
+      key = prefix+count.to_s
+      entry = params.delete(key)
+      break unless entry # no key means end of sequence
+      array << entry if entry.length > 0
+      count += 1
+    end
+    params[prefix] = array
+  end
   _json :"actions/#{params[:file]}"
 end
 
@@ -173,7 +192,6 @@
 
 get '/group/' do
   @groups = Group.list
-  @podlings = ASF::Podling.to_h
   _html :groups
 end
 
@@ -216,7 +234,7 @@
   @auth = Auth.info(env)
 
   user = ASF::Person.find(env.user)
-  @auth[:ipmc] = ASF::Committee.find('incubator').members.include? user
+  @auth[:ipmc] = ASF::Committee.find('incubator').owners.include? user
 
   @ppmc = PPMC.serialize(name, env)
   pass unless @ppmc
diff --git a/www/roster/models/committee.rb b/www/roster/models/committee.rb
index 76a64ff..6798e07 100644
--- a/www/roster/models/committee.rb
+++ b/www/roster/models/committee.rb
@@ -3,7 +3,7 @@
     response = {}
 
     pmc = ASF::Committee.find(id)
-    return if pmc.nonpmc? # Only show PMCs
+    return unless pmc.pmc? # Only show PMCs
     members = pmc.owners
     committers = pmc.committers
     return if members.empty? and committers.empty?
@@ -37,6 +37,7 @@
       subscribers, subtime = ASF::MLIST.list_subscribers(pmc.mail_list) # counts only
       analysePrivateSubs = currentUser.asf_member?
       unless analysePrivateSubs # check for private moderator if not already allowed access
+        # TODO match using canonical emails
         user_mail = currentUser.all_mail || []
         pMods = moderators["private@#{pmc.mail_list}.apache.org"] || []
         analysePrivateSubs = !(pMods & user_mail).empty?
@@ -52,19 +53,37 @@
       lists = lists.select {|list, mode| mode == 'public'}
     end
 
-    roster = pmc.roster.dup
-    roster.each {|key, info| info[:role] = 'PMC member'}
+    roster = pmc.roster.dup # from committee-info
+    # ensure PMC members are all processed even they don't belong to the owner group
+    roster.each do |key, info|
+      info[:role] = 'PMC member'
+      next if pmc.ownerids.include?(key) # skip the rest (expensive) if person is in the owner group
+      person = ASF::Person[key]
+      if analysePrivateSubs
+        # Analyse the subscriptions, matching against canonicalised personal emails
+        allMail = person.all_mail.map{|m| ASF::Mail.to_canonical(m.downcase)}
+        # pSubs is already downcased
+        # TODO should it be canonicalised as well above?
+        roster[key]['notSubbed'] = (allMail & pSubs.map{|m| ASF::Mail.to_canonical(m)}).empty?
+        unMatchedSubs.delete_if {|k| allMail.include? ASF::Mail.to_canonical(k.downcase)}
+        unMatchedSecSubs.delete_if {|k| allMail.include? ASF::Mail.to_canonical(k.downcase)}
+      end
+      roster[key]['githubUsername'] = (person.attrs['githubUsername'] || []).join(', ')
+    end
 
-    members.each do |person|
+    members.each do |person| # process the owners
       roster[person.id] ||= {
         name: person.public_name, 
-        role: 'PMC member'
+        role: 'PMC member' # TODO not strictly true, as CI is the canonical source
       }
       if analysePrivateSubs
-        allMail = person.all_mail.map{|m| m.downcase}
-        roster[person.id]['notSubbed'] = (allMail & pSubs).empty?
-        unMatchedSubs.delete_if {|k| allMail.include? k.downcase}
-        unMatchedSecSubs.delete_if {|k| allMail.include? k.downcase}
+        # Analyse the subscriptions, matching against canonicalised personal emails
+        allMail = person.all_mail.map{|m| ASF::Mail.to_canonical(m.downcase)}
+        # pSubs is already downcased
+        # TODO should it be canonicalised as well above?
+        roster[person.id]['notSubbed'] = (allMail & pSubs.map{|m| ASF::Mail.to_canonical(m)}).empty?
+        unMatchedSubs.delete_if {|k| allMail.include? ASF::Mail.to_canonical(k.downcase)}
+        unMatchedSecSubs.delete_if {|k| allMail.include? ASF::Mail.to_canonical(k.downcase)}
       end
       roster[person.id]['ldap'] = true
       roster[person.id]['githubUsername'] = (person.attrs['githubUsername'] || []).join(', ')
@@ -114,7 +133,7 @@
       }
       nonASFmails.each {|k,v|
         @people.each do |person|
-          if person[:mail].any? {|mail| mail.downcase == k.downcase}
+          if person[:mail].any? {|mail| ASF::Mail.to_canonical(mail.downcase) == ASF::Mail.to_canonical(k.downcase)}
             nonASFmails[k] = person[:id]
           end
         end
diff --git a/www/roster/models/committer.rb b/www/roster/models/committer.rb
index 34f2733..618bdbb 100644
--- a/www/roster/models/committer.rb
+++ b/www/roster/models/committer.rb
@@ -44,7 +44,9 @@
 
     response[:name] = name
 
-    response[:mail] = person.all_mail
+    response[:email_forward] = person.mail # forwarding
+    response[:email_alt] = person.alt_email # alternates
+    response[:email_other] = person.all_mail - person.mail - person.alt_email # others (ASF mail/ICLA mail if different)
 
     unless person.pgp_key_fingerprints.empty?
       response[:pgp] = person.pgp_key_fingerprints 
@@ -54,12 +56,14 @@
       response[:ssh] = person.ssh_public_keys
     end
 
+    response[:host] = person.attrs['host'] || ['(none)']
+
     if person.attrs['asf-sascore']
-      response[:sascore] = person.attrs['asf-sascore'].first
+      response[:sascore] = person.attrs['asf-sascore'].first # should only be one, but is returned as array
     end
 
     if person.attrs['githubUsername']
-      response[:githubUsername] = person.githubUsername
+      response[:githubUsername] = person.attrs['githubUsername'] # always return array
     end
 
     response[:urls] = person.urls unless person.urls.empty?
@@ -69,7 +73,8 @@
     response[:groups] = person.services
     response[:committer] = []
     response[:podlings] = []
-    pmc_names = ASF::Committee.pmcs.map(&:name) # From CI
+    pmcs = ASF::Committee.pmcs
+    pmc_names = pmcs.map(&:name) # From CI
     podlings = ASF::Podling.current.map(&:id)
 
     # Add group names unless they are a PMC group
@@ -182,6 +187,23 @@
       end
     end
 
+		response[:pmcs] = []
+		response[:nonpmcs] = []
+
+		pmcs.each do |pmc|
+  		response[:pmcs] << pmc.name if pmc.roster.include?(person.id)
+		  response[:chairOf] << pmc.name if pmc.chairs.map{|ch| ch[:id]}.include?(person.id)
+		end
+		response[:pmcs].sort!
+
+		response[:nonPMCchairOf] = [] # use separate list to avoid missing pmc-chair warnings
+    nonpmcs = ASF::Committee.nonpmcs
+    nonpmcs.each do |nonpmc|
+      response[:nonpmcs] << nonpmc.name if nonpmc.roster.include?(person.id)
+      response[:nonPMCchairOf] << nonpmc.name if nonpmc.chairs.map{|ch| ch[:id]}.include?(person.id)
+    end
+    response[:nonpmcs].sort!
+
     response
   end
 end
diff --git a/www/roster/models/group.rb b/www/roster/models/group.rb
index 8de7346..212cbf2 100644
--- a/www/roster/models/group.rb
+++ b/www/roster/models/group.rb
@@ -10,7 +10,7 @@
     groups.map! {|group| [group, "LDAP group"]}
 
     # add services...
-    groups += ASF::Service.listcns.map {|service| [service, "LDAP service"]}
+    groups += ASF::Service.listcns.reject{|s| s=='apldap'}.map {|service| [service, "LDAP service"]}
 
     # add authorization (asf and pit)
     groups += ASF::Authorization.new('asf').to_h.
diff --git a/www/roster/models/nonpmc.rb b/www/roster/models/nonpmc.rb
index 8453fcb..6b7776c 100644
--- a/www/roster/models/nonpmc.rb
+++ b/www/roster/models/nonpmc.rb
@@ -8,7 +8,7 @@
     committers = cttee.committers
     # Hack to fix unusual mail_list values e.g. press@apache.org
     mail_list = cttee.mail_list.sub(/@.*/,'')
-    mail_list = 'legal' if mail_list =~ /^legal-/
+    mail_list = 'legal' if mail_list =~ /^legal-/ unless cttee.name == 'dataprivacy'
     mail_list = 'fundraising' if mail_list =~ /^fundraising-/
 
     ASF::Committee.load_committee_info
diff --git a/www/roster/models/ppmc.rb b/www/roster/models/ppmc.rb
index 400a4e4..a833508 100644
--- a/www/roster/models/ppmc.rb
+++ b/www/roster/models/ppmc.rb
@@ -9,7 +9,8 @@
       list =~ /^(incubator-)?#{ppmc.mail_list}\b/
     end
 
-    members = ppmc.members
+    committers = ppmc.members
+    owners = ppmc.owners
 
     # separate out the known ASF members and extract any matching committer details
     unknownSubs = []
@@ -25,12 +26,13 @@
     unMatchedSubs = [] # unknown private@ subscribers
     currentUser = ASF::Person.find(env.user)
     analysePrivateSubs = false # whether to show missing private@ subscriptions
-    if currentUser.asf_member? or members.include? currentUser
+    if currentUser.asf_member? or owners.include? currentUser
       require 'whimsy/asf/mlist'
       moderators, modtime = ASF::MLIST.list_moderators(ppmc.mail_list, true)
       subscribers, subtime = ASF::MLIST.list_subscribers(ppmc.mail_list, true) # counts only
       analysePrivateSubs = currentUser.asf_member?
       unless analysePrivateSubs # check for private moderator if not already allowed access
+        # TODO match using canonical emails
         user_mail = currentUser.all_mail || []
         pMods = moderators["private@#{ppmc.mail_list}.apache.org"] || []
         analysePrivateSubs = !(pMods & user_mail).empty?
@@ -49,25 +51,38 @@
     pmc = ASF::Committee.find('incubator')
     ipmc = pmc.owners
     incubator_committers = pmc.committers
-    owners = ppmc.owners
 
-    roster = members.map {|person|
-      notSubbed = false
-      if analysePrivateSubs and owners.include? person
-        allMail = person.all_mail.map{|m| m.downcase}
-        notSubbed = (allMail & pSubs).empty?
-        unMatchedSubs.delete_if {|k| allMail.include? k.downcase}
-      end
+    # Preload the committers; if a person has another role it will be set up below
+    roster = committers.map {|person|
       [person.id, {
-        notSubbed: notSubbed,
+        # notSubbed does not apply
         name: person.public_name, 
         member: person.asf_member?,
         icommit: incubator_committers.include?(person),
-        role: (owners.include?(person) ? 'PPMC Member' : 'Committer'),
+        role: 'Committer',
         githubUsername: (person.attrs['githubUsername'] || []).join(', ')
       }]
     }.to_h
 
+    # Merge the PPMC members (owners)
+    owners.each do |person|
+      notSubbed = false
+      if analysePrivateSubs
+        allMail = person.all_mail.map{|m| ASF::Mail.to_canonical(m.downcase)}
+        notSubbed = (allMail & pSubs.map{|m| ASF::Mail.to_canonical(m)}).empty?
+        unMatchedSubs.delete_if {|k| allMail.include? ASF::Mail.to_canonical(k.downcase)}
+      end
+      roster[person.id] = {
+        notSubbed: notSubbed,
+        name: person.public_name, 
+        member: person.asf_member?,
+        icommit: incubator_committers.include?(person),
+        role: 'PPMC Member',
+        githubUsername: (person.attrs['githubUsername'] || []).join(', ')
+      }
+    end
+
+    # Finally merge the mentors
     ppmc.mentors.each do |mentor|
       person = ASF::Person.find(mentor)
       roster[person.id] = {
@@ -79,9 +94,9 @@
         githubUsername: (person.attrs['githubUsername'] || []).join(', ')
       }
       if analysePrivateSubs
-        allMail = person.all_mail.map{|m| m.downcase}
-        roster[person.id]['notSubbed'] = (allMail & pSubs).empty?
-        unMatchedSubs.delete_if {|k| allMail.include? k.downcase}
+        allMail = person.all_mail.map{|m| ASF::Mail.to_canonical(m.downcase)}
+        roster[person.id]['notSubbed'] = (allMail & pSubs.map{|m| ASF::Mail.to_canonical(m)}).empty?
+        unMatchedSubs.delete_if {|k| allMail.include? ASF::Mail.to_canonical(k.downcase)}
       end
     end
 
@@ -108,7 +123,7 @@
       }
       nonASFmails.each {|k,v|
         @people.each do |person|
-          if person[:mail].any? {|mail| mail.downcase == k.downcase}
+          if person[:mail].any? {|mail| ASF::Mail.to_canonical(mail.downcase) == ASF::Mail.to_canonical(k.downcase)}
             nonASFmails[k] = person[:id]
           end
         end
@@ -128,7 +143,7 @@
       mentors: ppmc.mentors,
       hasLDAP: ppmc.hasLDAP?,
       owners: owners.map {|person| person.id},
-      committers: members.map {|person| person.id},
+      committers: committers.map {|person| person.id},
       roster: roster,
       mail: Hash[lists.sort],
       moderators: moderators,
diff --git a/www/roster/public_committee_info.rb b/www/roster/public_committee_info.rb
index b9dcad3..f34bb6d 100644
--- a/www/roster/public_committee_info.rb
+++ b/www/roster/public_committee_info.rb
@@ -54,7 +54,7 @@
     chair: Hash[committee.chairs.map {|chair|
       [chair[:id], {:name => chair[:name]} ]}],
     roster: committee.roster.sort.to_h, # sort entries by uid
-    pmc: !committee.nonpmc?
+    pmc: committee.pmc?
   }]
 }]
 
@@ -94,6 +94,7 @@
 
   info[:committees].each { |pmc, entry|
     next if pmc == 'infrastructure' # no dates
+    Wunderbar.warn "#{pmc}: no description found" if entry[:pmc] && ! entry[:description]
     previouspmc = previous[pmc] # get the original details (if any)
     if previouspmc # we have an existing entry
       entry[:roster].each { |name, value|
diff --git a/www/roster/public_json_common.rb b/www/roster/public_json_common.rb
index c2ff8a2..ff4de5c 100644
--- a/www/roster/public_json_common.rb
+++ b/www/roster/public_json_common.rb
@@ -7,7 +7,7 @@
 # Status updates: https://whimsy-test.apache.org/status/
 #
 
-$LOAD_PATH.unshift File.realpath(File.expand_path('../../../lib', __FILE__))
+$LOAD_PATH.unshift '/srv/whimsy/lib'
 require 'whimsy/asf'
 require 'json'
 
diff --git a/www/roster/public_ldap_projects.rb b/www/roster/public_ldap_projects.rb
index 976386c..007fd52 100644
--- a/www/roster/public_ldap_projects.rb
+++ b/www/roster/public_ldap_projects.rb
@@ -39,7 +39,7 @@
 
 # committee status
 committees = Hash[ASF::Committee.load_committee_info.map {|committee|
-  [ committee.name.gsub(/[^-\w]/,'') , !committee.nonpmc? ]
+  [ committee.name.gsub(/[^-\w]/,'') , committee.pmc? ]
 }]
 
 # podling status
diff --git a/www/roster/views/actions/email_alt.json.rb b/www/roster/views/actions/email_alt.json.rb
new file mode 100644
index 0000000..22ec0ab
--- /dev/null
+++ b/www/roster/views/actions/email_alt.json.rb
@@ -0,0 +1,35 @@
+#
+# Update PGP keys attribute for a committer
+#
+
+person = ASF::Person.find(@userid)
+
+# report the previous value in the response
+_previous alt_email: person.alt_email # returns empty array if not defined
+
+if @email_alt  # must agree with email_alt.js.rb
+
+  # report the new values
+  _replacement alt_email: @email_alt
+
+  @email_alt.each do |mail|
+    unless mail.match(URI::MailTo::EMAIL_REGEXP)
+      _error "Invalid email address '#{mail}'"
+      return
+    end
+    if mail.downcase.end_with? 'apache.org'
+      _error "Invalid email address '#{mail}' (must not be apache.org)"
+      return
+    end
+  end
+
+  # update LDAP
+  unless @dryrun
+    _ldap.update do
+      person.modify 'asf-altEmail', @email_alt
+    end
+  end
+end
+
+# return updated committer info
+_committer Committer.serialize(@userid, env)
diff --git a/www/roster/views/actions/email_forward.json.rb b/www/roster/views/actions/email_forward.json.rb
new file mode 100644
index 0000000..91826ca
--- /dev/null
+++ b/www/roster/views/actions/email_forward.json.rb
@@ -0,0 +1,39 @@
+#
+# Update PGP keys attribute for a committer
+#
+
+person = ASF::Person.find(@userid)
+
+# report the previous value in the response
+_previous mail: person.attrs['mail']
+
+if @email_forward  # must agree with email_forward.js.rb
+
+  # report the new values
+  _replacement mail: @email_forward
+
+  @email_forward.each do |mail|
+    unless mail.match(URI::MailTo::EMAIL_REGEXP)
+      _error "Invalid email address '#{mail}'"
+      return
+    end
+    if mail.downcase.end_with? 'apache.org'
+      _error "Invalid email address '#{mail}' (must not be apache.org)"
+      return
+    end
+  end
+
+  if  @email_forward.empty?
+    _error "Forwarding email address must not be empty!"
+  end
+
+  # update LDAP
+  unless @dryrun
+    _ldap.update do
+      person.modify 'mail', @email_forward
+    end
+  end
+end
+
+# return updated committer info
+_committer Committer.serialize(@userid, env)
diff --git a/www/roster/views/actions/github.json.rb b/www/roster/views/actions/github.json.rb
index 559d962..8273345 100644
--- a/www/roster/views/actions/github.json.rb
+++ b/www/roster/views/actions/github.json.rb
@@ -2,16 +2,33 @@
 # Update GitHub username attribute for a committer
 #
 
-# update LDAP
-_ldap.update do
-  person = ASF::Person.find(@userid)
+person = ASF::Person.find(@userid)
 
-  # report the previous value in the response
-  _previous githubUsername: person.attrs['githubUsername']
+# report the previous value in the response
+_previous githubUsername: person.attrs['githubUsername']
 
-  if @githubuser and not @dryrun
-    person.modify 'githubUsername', @githubuser
+if @githubuser
+
+  # report the new values
+  _replacement githubUsername: @githubuser
+
+  @githubuser.each do |name|
+    # Should agree with the validation in github.js.rb
+    unless name =~ /^[-0-9a-zA-Z]+$/ # TODO: might need extending?
+      _error "'#{name}' is invalid: must be alphanumeric (or -)"
+      return
+    end
+    # TODO: perhaps check that https://github.com/name exists?    
   end
+
+  unless @dryrun
+    names = @githubuser.uniq{|n| n.downcase} # duplicates not allowed; case-blind
+    # update LDAP
+    _ldap.update do
+       person.modify 'githubUsername', names
+    end
+  end
+
 end
 
 # return updated committer info
diff --git a/www/roster/views/actions/pgpkeys.json.rb b/www/roster/views/actions/pgpkeys.json.rb
new file mode 100644
index 0000000..4a19d87
--- /dev/null
+++ b/www/roster/views/actions/pgpkeys.json.rb
@@ -0,0 +1,38 @@
+#
+# Update PGP keys attribute for a committer
+#
+
+person = ASF::Person.find(@userid)
+
+# report the previous value in the response
+_previous asf_pgpKeyFingerprint: person.attrs['asf-pgpKeyFingerprint']
+
+if @pgpkeys  # must agree with pgpkeys.js.rb
+
+  # report the new values
+  _replacement pgpKeyFingerprint: @pgpkeys
+
+  fprints = [] # collect the fingerprints
+  @pgpkeys.each do |fp|
+    fprint = fp.gsub(' ','').upcase
+    if fprint =~ /^[0-9A-F]{40}$/ 
+      fprints << fprint        
+    else
+      _error "'#{fp}' is invalid: expecting 40 hex characters (plus optional spaces)"
+      return
+    end
+  end
+  # convert to canonical format
+  fprints = fprints.uniq.map do |n| # duplicates not allowed
+   "%s %s %s %s %s  %s %s %s %s %s" % n.scan(/..../)
+  end
+  # update LDAP
+  unless @dryrun
+    _ldap.update do
+      person.modify 'asf-pgpKeyFingerprint', fprints
+    end
+  end
+end
+
+# return updated committer info
+_committer Committer.serialize(@userid, env)
diff --git a/www/roster/views/actions/sascore.json.rb b/www/roster/views/actions/sascore.json.rb
index afee769..62ba2d1 100644
--- a/www/roster/views/actions/sascore.json.rb
+++ b/www/roster/views/actions/sascore.json.rb
@@ -2,14 +2,14 @@
 # Update LDAP SpamAssassin score attribute for a committer
 #
 
-# update LDAP
-_ldap.update do
-  person = ASF::Person.find(@userid)
+person = ASF::Person.find(@userid)
 
-  # report the previous value in the response
-  _previous sascore: person.attrs['asf-sascore']
+# report the previous value in the response
+_previous sascore: person.attrs['asf-sascore']
 
-  if @sascore and not @dryrun
+if @sascore and not @dryrun
+  # update LDAP
+  _ldap.update do
     person.modify 'asf-sascore', @sascore
   end
 end
diff --git a/www/roster/views/actions/sshkeys.json.rb b/www/roster/views/actions/sshkeys.json.rb
new file mode 100644
index 0000000..6476c70
--- /dev/null
+++ b/www/roster/views/actions/sshkeys.json.rb
@@ -0,0 +1,26 @@
+#
+# Update PGP keys attribute for a committer
+#
+
+person = ASF::Person.find(@userid)
+
+# report the previous value in the response
+_previous sshPublicKey: person.attrs['sshPublicKey']
+
+if @sshkeys  # must agree with sshkeys.js.rb
+
+  # report the new values
+  _replacement sshPublicKey: @sshkeys
+
+  # TODO: add validation?
+
+  # update LDAP
+  unless @dryrun
+    _ldap.update do
+      person.modify 'sshPublicKey', @sshkeys
+    end
+  end
+end
+
+# return updated committer info
+_committer Committer.serialize(@userid, env)
diff --git a/www/roster/views/actions/urls.json.rb b/www/roster/views/actions/urls.json.rb
new file mode 100644
index 0000000..be980b0
--- /dev/null
+++ b/www/roster/views/actions/urls.json.rb
@@ -0,0 +1,38 @@
+#
+# Update PGP keys attribute for a committer
+#
+
+person = ASF::Person.find(@userid)
+
+# report the previous value in the response
+_previous asf_personalURL: person.attrs['asf-personalURL']
+
+if @urls  # must agree with urls.js.rb
+
+  # report the new values
+  _replacement asf_personalURL: @urls
+
+  @urls.each do |url|
+#    next
+    begin
+      uri = URI.parse(url)
+    rescue
+      _error "Cannot parse URL: #{url}"
+      return
+    end
+    unless uri.scheme =~ /^https?$/ && uri.host.length > 5
+      _error "Invalid http(s) URL: #{url}"
+      return
+    end
+  end
+
+  # update LDAP
+  unless @dryrun
+    _ldap.update do
+      person.modify 'asf-personalURL', @urls
+    end
+  end
+end
+
+# return updated committer info
+_committer Committer.serialize(@userid, env)
diff --git a/www/roster/views/app.js.rb b/www/roster/views/app.js.rb
index 6e089a7..796aa9b 100644
--- a/www/roster/views/app.js.rb
+++ b/www/roster/views/app.js.rb
@@ -16,10 +16,12 @@
 require_relative 'nonpmc/add'
 require_relative 'nonpmc/mod'
 
-require_relative 'person'
+require_relative 'person/main'
 require_relative 'person/fullname'
 require_relative 'person/urls'
-require_relative 'person/email'
+require_relative 'person/email_alt'
+require_relative 'person/email_forward'
+require_relative 'person/email_other'
 require_relative 'person/pgpkeys'
 require_relative 'person/sshkeys'
 require_relative 'person/github'
diff --git a/www/roster/views/committees.html.rb b/www/roster/views/committees.html.rb
index d451e6f..89401ce 100644
--- a/www/roster/views/committees.html.rb
+++ b/www/roster/views/committees.html.rb
@@ -7,25 +7,42 @@
   _link rel: 'stylesheet', href: "stylesheets/app.css?#{cssmtime}"
   _whimsy_body(
     title: 'ASF PMC Listing',
+    subtitle: 'List of all Top Level Projects',
+    relatedtitle: 'More Useful Links',
+    related: {
+      "/committers/tools" => "Whimsy All Tools Listing",
+      "https://svn.apache.org/repos/private/committers/" => "Checkout the private 'committers' repo for Committers",
+      "https://github.com/apache/whimsy/blob/master/www#{ENV['SCRIPT_NAME']}" => "See This Source Code",
+      "mailto:dev@whimsical.apache.org?subject=[FEEDBACK] members/index idea" => "Email Feedback To dev@whimsical"
+    },
+    helpblock: -> {
+      _p do
+        _ 'A full list of Apache PMCs; click on the name for a detail page about that PMC. '
+        _ 'You can also view (Member-private) '
+        _a href: '/roster/nonpmc/' do
+          _span.glyphicon.glyphicon_lock :aria_hidden, class: 'text-primary', aria_label: 'ASF Members Private'
+          _ 'Non-PMC Committees (Brand, Legal, etc.)'
+        end
+        _ ' and '
+        _a href: '/roster/group/' do
+          _span.glyphicon.glyphicon_lock :aria_hidden, class: 'text-primary', aria_label: 'ASF Members Private'
+          _ 'Other Groups of various kinds (from LDAP or auth).'
+        end
+      end
+      _p do
+        _ 'Chair names in BOLD below are also ASF Members.  Click on column names in table to sort; jump to A-Z project listings here:'
+        _br 
+        "ABCDEFGHIJKLMNOPQRSTUVWXYZ".each_char do |c|
+          _a c, href: "committee/##{c}"
+        end
+        _ '(note: the links only work properly if the page is sorted by project name ascending)'
+      end
+    },
     breadcrumbs: {
       roster: '.',
       committee: 'committee/'
     }
   ) do
-    _p do
-      _ 'A full list of Apache PMCs; click on the name for a detail page about that PMC.  Non-PMC groups of various kinds '
-      _a href: '/roster/group/' do
-        _span.glyphicon.glyphicon_lock :aria_hidden, class: 'text-primary', aria_label: 'ASF Members Private'
-        _ 'are listed privately.'
-      end
-    end
-    _p do
-      _ 'Click on column names to sort.'
-      _{"&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;"}
-      "ABCDEFGHIJKLMNOPQRSTUVWXYZ".each_char do |c|
-        _a c, href: "committee/##{c}"
-      end
-    end
 
     _table.table.table_hover do
       _thead do
@@ -39,14 +56,15 @@
       prev_letter=nil
       @committees.sort_by {|pmc| pmc.display_name.downcase}.each do |pmc|
         letter = pmc.display_name.upcase[0]
-        _tr_ do
+        if letter != prev_letter
+          options = {id: letter}
+        else
+          options = {}
+        end
+        prev_letter = letter
+        _tr_ options do
           _td do
-            if letter != prev_letter
-              _a pmc.display_name, href: "committee/#{pmc.name}", id: letter
-            else
-              _a pmc.display_name, href: "committee/#{pmc.name}"
-            end
-            prev_letter = letter
+            _a pmc.display_name, href: "committee/#{pmc.name}"
           end
 
           _td do
diff --git a/www/roster/views/groups.html.rb b/www/roster/views/groups.html.rb
index 446fb19..4004e31 100644
--- a/www/roster/views/groups.html.rb
+++ b/www/roster/views/groups.html.rb
@@ -59,7 +59,6 @@
             _tr do
               _th.sorting_asc 'Name', data_sort: 'string-ins'
               _th 'Group type', data_sort: 'string'
-              _th 'Notes', data_sort: 'notes'
             end
           end
 
@@ -69,16 +68,6 @@
               _tr_ do
                 _td {_a name, href: "group/#{name}"}
                 _td type
-
-                if @podlings[name]
-                  if @podlings[name].status == 'retired'
-                    _td.issue "retired podling"
-                  else
-                    _td "#{@podlings[name].status} podling"
-                  end
-                else
-                  _td
-                end
               end
             end
           end
diff --git a/www/roster/views/index.html.rb b/www/roster/views/index.html.rb
index 5834de2..5ab9cfd 100644
--- a/www/roster/views/index.html.rb
+++ b/www/roster/views/index.html.rb
@@ -26,7 +26,12 @@
             _a 'Committers', href: 'committer/'
           end
 
-          _td 'Search for committers by name, user id, or email address'
+          _td do
+            _ 'Search for committers by name, user id, or email address'
+            _ ' (includes '
+            _ @committers.select{|c| c.inactive?}.length
+            _ ' inactive accounts)'
+          end
         end
 
         ### members
diff --git a/www/roster/views/nonpmcs.html.rb b/www/roster/views/nonpmcs.html.rb
index adedeec..9b7b4ff 100644
--- a/www/roster/views/nonpmcs.html.rb
+++ b/www/roster/views/nonpmcs.html.rb
@@ -13,7 +13,10 @@
     }
   ) do
     _p do
-      _ 'A full list of Apache committees that are not PMCs; click on the name for a detail page about that committee.  Other groups of various kinds '
+      _ 'A full list of Apache committees that are not PMCs; click on the name for a detail page about that committee.'
+      _ '(from committee-info.txt)'
+      _br
+      _ 'Other groups of various kinds '
       _a href: '/roster/group/' do
         _span.glyphicon.glyphicon_lock :aria_hidden, class: 'text-primary', aria_label: 'ASF Members Private'
         _ 'are listed privately.'
diff --git a/www/roster/views/person/email.js.rb b/www/roster/views/person/email.js.rb
deleted file mode 100644
index ee3fd55..0000000
--- a/www/roster/views/person/email.js.rb
+++ /dev/null
@@ -1,21 +0,0 @@
-#
-# Render and edit a person's E-mail addresses
-#
-
-class PersonEmail < Vue
-  def render
-    committer = @@person.state.committer
-
-    _div.row do
-      _div.name 'Email addresses'
-
-      _div.value do
-        _ul committer.mail do |url|
-          _li do
-            _a url, href: 'mailto:' + url
-          end
-        end
-      end
-    end
-  end
-end
diff --git a/www/roster/views/person/email_alt.js.rb b/www/roster/views/person/email_alt.js.rb
new file mode 100644
index 0000000..f8f5d71
--- /dev/null
+++ b/www/roster/views/person/email_alt.js.rb
@@ -0,0 +1,50 @@
+#
+# Render and edit a person's alt E-mail addresses
+#
+
+class PersonEmailAlt < Vue
+  def render
+    committer = @@person.state.committer
+
+    _div.row data_edit: 'email_alt' do
+      _div.name 'Email addresses (alt)'
+
+      _div.value do
+
+        if @@edit == :email_alt
+
+          _form method: 'post' do
+            current = 1
+            prefix = 'email_alt' # must agree with email_alt.json.rb
+            _input type: 'hidden', name: 'array_prefix', value: prefix
+
+            _div committer.email_alt do |key|
+              _input name: prefix + current, value: key, size: 30
+              _br              
+              current += 1
+            end
+            # Spare field to allow new entry to be added
+            _input name: prefix + current, placeholder: '<alternate email>', size: 30
+            _br             
+
+            _input type: 'submit', value: 'submit'
+          end
+
+        else
+
+          if committer.email_alt.length == 0
+            _ul do
+              _li '(none defined)'
+            end
+          else
+            _ul committer.email_alt do |mail|
+              _li do
+                _a mail, href: 'mailto:' + mail
+              end
+            end
+          end
+        end
+      end
+    end
+  end
+end
diff --git a/www/roster/views/person/email_forward.js.rb b/www/roster/views/person/email_forward.js.rb
new file mode 100644
index 0000000..526c906
--- /dev/null
+++ b/www/roster/views/person/email_forward.js.rb
@@ -0,0 +1,44 @@
+#
+# Render and edit a person's forward E-mail addresses
+#
+
+class PersonEmailForwards < Vue
+  def render
+    committer = @@person.state.committer
+
+    _div.row data_edit: 'email_forward' do
+      _div.name 'Email forwarded to'
+
+      _div.value do
+
+        if @@edit == :email_forward
+
+          _form method: 'post' do
+            current = 1
+            prefix = 'email_forward' # must agree with email_forward.json.rb
+            _input type: 'hidden', name: 'array_prefix', value: prefix
+
+            _div committer.email_forward do |key|
+              _input name: prefix + current, value: key, size: 30
+              _br              
+              current += 1
+            end
+            # Spare field to allow new entry to be added
+            _input name: prefix + current, placeholder: '<forwarding email>', size: 30
+            _br             
+
+            _input type: 'submit', value: 'submit'
+          end
+
+        else
+
+          _ul committer.email_forward do |mail|
+            _li do
+              _a mail, href: 'mailto:' + mail
+            end
+          end
+        end
+      end
+    end
+  end
+end
diff --git a/www/roster/views/person/email_other.js.rb b/www/roster/views/person/email_other.js.rb
new file mode 100644
index 0000000..08d12cb
--- /dev/null
+++ b/www/roster/views/person/email_other.js.rb
@@ -0,0 +1,21 @@
+#
+# Render a person's other E-mail address(es)
+#
+
+class PersonEmailOther < Vue
+  def render
+    committer = @@person.state.committer
+
+    _div.row do
+      _div.name 'Email addresses (other)'
+
+      _div.value do
+        _ul committer.email_other do |mail|
+          _li do
+              _a mail, href: 'mailto:' + mail
+          end
+        end
+      end
+    end
+  end
+end
diff --git a/www/roster/views/person/github.js.rb b/www/roster/views/person/github.js.rb
index 27a9195..6f0991a 100644
--- a/www/roster/views/person/github.js.rb
+++ b/www/roster/views/person/github.js.rb
@@ -14,16 +14,39 @@
         if @@edit == :github
 
           _form method: 'post' do
-            _input name: 'githubuser', value: committer.githubUsername
+            current = 1
+            prefix = 'githubuser'
+            _input type: 'hidden', name: 'array_prefix', value: prefix
+
+            _div committer.githubUsername do |name|
+              _input style: 'font-family:Monospace', size: 20, name: prefix + current, value: name
+              _br              
+              current += 1
+            end
+            # Spare field to allow new entry to be added
+            _input style: 'font-family:Monospace', size: 20, name: prefix + current, placeholder: '<new GitHub name>'
+            _br             
+            
             _input type: 'submit', value: 'submit'
           end
 
         else
-
-          _a committer.githubUsername, 
-            href: "https://github.com/" + committer.githubUsername
+          if committer.githubUsername.empty?
+            _ul do
+              _li '(none defined)'
+            end
+          else
+            _ul committer.githubUsername do |gh|
+              _li do
+                _a gh, href: "https://github.com/" + gh +"/" # / catches trailing spaces
+                unless gh =~ /^[-0-9a-zA-Z]+$/ # should agree with the validation in github.json.rb
+                  _ ' '
+                  _span.bg_warning "Invalid: '#{gh}' expecting only alphanumeric and '-'"
+                end
+              end
+            end
+          end
         end
-
       end
     end
   end
diff --git a/www/roster/views/person.js.rb b/www/roster/views/person/main.js.rb
similarity index 72%
rename from www/roster/views/person.js.rb
rename to www/roster/views/person/main.js.rb
index dc6f14d..1a53557 100644
--- a/www/roster/views/person.js.rb
+++ b/www/roster/views/person/main.js.rb
@@ -23,18 +23,19 @@
     end
 
     # Personal URL
-    if @committer.urls
-      _PersonUrls person: self
+    if @committer.urls || @auth
+      @committer.urls ||= []
+      _PersonUrls person: self, edit: @edit
     end
 
-    # Committees
-    committees = @committer.committees
-    unless committees.empty?
+    # PMCs
+    noPMCsub = false    
+    pmcs = @committer.pmcs
+    unless pmcs.empty?
       _div.row do
-        _div.name 'Committees'
+        _div.name 'PMCs'
         _div.value do
-          noPMCsub = false
-          _ul committees do |pmc|
+          _ul pmcs do |pmc|
             _li {
               _a pmc, href: "committee/#{pmc}"
               if @committer.privateNosub
@@ -46,19 +47,48 @@
               if @committer.chairOf.include? pmc
                 _ ' (chair)'
               end
+              unless @committer.committees.include?(pmc)
+                _b ' (not in LDAP committee group)'
+              end
             }
           end
+          if noPMCsub
+                  _br
+            _p {
+              _ '(*) could not find a subscription to the private@ mailing list for this PMC'
+              _br
+              _ 'Perhaps the subscription address is not listed in the LDAP record'
+              _br 
+              _ '(Note that digest subscriptions are not currently included)'
+            }
+          end
+        end
+      end
+    end
 
-	  if noPMCsub
-            _br
-	    _p {
-	      _ '(*) could not find a subscription to the private@ mailing list for this committee'
-	      _br
-	      _ 'Perhaps the subscription address is not listed in the LDAP record'
-        _br 
-        _ '(Note that digest subscriptions are not currently included)'
-	    }
-	  end
+    # Committees
+    missingPMCs = false
+    committees = @committer.committees
+    unless committees.empty?
+      _div.row do
+        _div.name 'Committees'
+        _div.value do
+          noPMCsub = false
+          _ul committees do |pmc|
+            next if  @committer.pmcs.include? pmc
+            missingPMCs = true
+            _li {
+              _a pmc, href: "committee/#{pmc}"
+              if @committer.chairOf.include? pmc
+                _ ' (chair)'
+              end
+            }
+          end
+          if missingPMCs
+            _ 'In LDAP committee group, but not on the corresponding PMC'
+          else
+            _ '(excludes PMCs listed above)'
+          end
         end
       end
     end
@@ -112,9 +142,33 @@
       end
     end
 
+    # Non-PMCs
+    nonpmcs = @committer.nonpmcs
+    unless nonpmcs.empty?
+      _div.row do
+        _div.name 'non-PMCs'
+        _div.value do
+          _ul nonpmcs do |nonpmc|
+            _li {
+              _a nonpmc, href: "nonpmc/#{nonpmc}"
+              if @committer.nonPMCchairOf.include? nonpmc
+                _ ' (chair)'
+              end
+            }
+          end
+        end
+      end
+    end
+    
     # Email addresses
-    if @committer.mail
-      _PersonEmail person: self
+    # always present
+    _PersonEmailForwards person: self, edit: @edit
+
+    # always present (even if an empty array)
+    _PersonEmailAlt person: self, edit: @edit
+
+    if @committer.email_other
+      _PersonEmailOther person: self # not editable
     end
 
     # Moderates
@@ -172,17 +226,29 @@
     end
 
     # PGP keys
-    if @committer.pgp
-      _PersonPgpKeys person: self
+    if @committer.pgp || @auth
+      @committer.pgp ||= []
+      _PersonPgpKeys person: self, edit: @edit
+    end
+
+    # hosts    
+    _div.row do
+      _div.name 'Host Access'
+      _div.value do
+        # pre avoids wrapping on hyphens and reduces number of lines on the page
+        _pre @committer.host.join(' ')
+      end
     end
 
     # SSH keys
-    if @committer.ssh
-      _PersonSshKeys person: self
+    if @committer.ssh || @auth
+      @committer.ssh ||= []
+      _PersonSshKeys person: self, edit: @edit
     end
 
     # GitHub username
-    if @committer.githubUsername
+    if @committer.githubUsername || @auth
+      @committer.githubUsername ||= []
       _PersonGitHub person: self, edit: @edit
     end
 
@@ -211,16 +277,16 @@
     # SpamAssassin score
     _PersonSascore person: self, edit: @edit
 
-    # modal dialog for dry run results
+    # modal dialog for dry run results and errors
     _div.modal.fade.wide_form tabindex: -1 do
       _div.modal_dialog do
         _div.modal_content do
           _div.modal_header do
             _button.close 'x', data_dismiss: 'modal'
-            _h4 'Dry run results'
+            _h4 @response_title
           end
           _div.modal_body do
-            _textarea value: JSON.stringify(@response, nil, 2), readonly: true
+            _textarea value: @response, readonly: true
           end
           _div.modal_footer do
             _button.btn.btn_default 'Close', data_dismiss: 'modal'
@@ -316,9 +382,17 @@
       },
 
       complete: ->(response) do
+        json = response.responseJSON
         # show results of dryrun
         if formData[0] and formData[0].name == 'dryrun'
-          @response = response.responseJSON
+          @response_title = 'Dry run results'
+          @response = JSON.stringify(json, nil, 2)
+          jQuery('div.modal').modal('show')
+        end
+
+        if json.error
+          @response_title = 'Error occurred'
+          @response = JSON.stringify(json, nil, 2)
           jQuery('div.modal').modal('show')
         end
 
diff --git a/www/roster/views/person/pgpkeys.js.rb b/www/roster/views/person/pgpkeys.js.rb
index 453fbd7..2c6b2d0 100644
--- a/www/roster/views/person/pgpkeys.js.rb
+++ b/www/roster/views/person/pgpkeys.js.rb
@@ -6,19 +6,55 @@
   def render
     committer = @@person.state.committer
 
-    _div.row do
+    _div.row data_edit: 'pgpkeys' do
       _div.name 'PGP keys'
 
       _div.value do
-        _ul committer.pgp do |key|
-          _li do
-            if key =~ /^[0-9a-fA-F ]+$/
-              _samp do
-                _a key, href: 'https://sks-keyservers.net/pks/lookup?' +
-                  'op=index&search=0x' + key.gsub(' ', '')
+        if @@edit == :pgpkeys
+
+          _form method: 'post' do
+            current = 1
+            prefix = 'pgpkeys' # must agree with pgpkeys.json.rb
+            _input type: 'hidden', name: 'array_prefix', value: prefix
+
+            _div committer.pgp do |key|
+              _input style: 'font-family:Monospace', size: 52, name: prefix + current, value: key
+              _br              
+              current += 1
+            end
+            # Spare field to allow new entry to be added
+            _input style: 'font-family:Monospace', size: 52, name: prefix + current, placeholder: '<enter a new 40 hex char key>'
+            _br             
+
+            _input type: 'submit', value: 'submit'
+          end
+
+        else
+          if committer.pgp.empty?
+            _ul do
+              _li '(none defined)'
+            end
+          else
+            _ul committer.pgp do |key|
+              nbsp = "\u00A0" # non-breaking space as UTF-8
+              keynb = key.gsub(' ', nbsp) # ensure multiple spaces appear as such
+              _li do
+                if key =~ /^[0-9a-fA-F ]+$/
+                  keysq = key.gsub(' ', '') # strip spaces for length check and lookup
+                  _samp style: 'font-family:Monospace' do
+                    _a keynb, href: 'https://sks-keyservers.net/pks/lookup?' +
+                      'op=index&fingerprint=on&search=0x' + keysq
+                    unless keysq.length == 40
+                      _span.bg_danger ' ?? Expecting exactly 40 hex characters (plus optional spaces)'
+                    end
+                  end
+                else
+                  _samp style: 'font-family:Monospace' do
+                    _ keynb
+                    _span.bg_danger ' ?? Expecting exactly 40 hex characters (plus optional spaces)'
+                  end
+                end
               end
-            else
-              _samp key
             end
           end
         end
diff --git a/www/roster/views/person/sshkeys.js.rb b/www/roster/views/person/sshkeys.js.rb
index 6b4ecdf..41b4142 100644
--- a/www/roster/views/person/sshkeys.js.rb
+++ b/www/roster/views/person/sshkeys.js.rb
@@ -6,13 +6,41 @@
   def render
     committer = @@person.state.committer
 
-    _div.row do
+    _div.row data_edit: 'sshkeys' do
       _div.name 'SSH keys'
 
       _div.value do
-        _ul committer.ssh do |key|
-          _li.ssh do
-            _pre.wide key
+
+        if @@edit == :sshkeys
+
+          _form method: 'post' do
+            current = 1
+            prefix = 'sshkeys' # must agree with sshkeys.json.rb
+            _input type: 'hidden', name: 'array_prefix', value: prefix
+
+            _div committer.ssh do |key|
+              _input style: 'font-family:Monospace', size: 100, name: prefix + current, value: key
+              _br              
+              current += 1
+            end
+            # Spare field to allow new entry to be added
+            _input style: 'font-family:Monospace', size: 100, name: prefix + current, placeholder: '<enter a new ssh key>'
+            _br             
+
+            _input type: 'submit', value: 'submit'
+          end
+
+        else
+          if committer.ssh.empty?
+            _ul do
+              _li '(none defined)'
+            end
+          else
+            _ul committer.ssh do |key|
+              _li.ssh do
+                _pre.wide key
+              end
+            end
           end
         end
       end
diff --git a/www/roster/views/person/urls.js.rb b/www/roster/views/person/urls.js.rb
index 660e4c6..c8de9a8 100644
--- a/www/roster/views/person/urls.js.rb
+++ b/www/roster/views/person/urls.js.rb
@@ -6,13 +6,40 @@
   def render
     committer = @@person.state.committer
 
-    _div.row do
+    _div.row data_edit: 'urls' do
       _div.name 'Personal URL'
 
       _div.value do
-        _ul committer.urls do |url|
-          _li {_a url, href: url}
-        end
+        if @@edit == :urls
+
+          _form method: 'post' do
+            current = 1
+            prefix = 'urls' # must agree with urls.json.rb
+            _input type: 'hidden', name: 'array_prefix', value: prefix
+
+            _div committer.urls do |url|
+              _input name: prefix + current, value: url
+              _br              
+              current += 1
+            end
+            # Spare field to allow new entry to be added
+            _input name: prefix + current, placeholder: '<enter a new URL>'
+            _br             
+
+            _input type: 'submit', value: 'submit'
+          end
+
+        else
+          if committer.urls.empty?
+            _ul do
+              _li '(none defined)'
+            end
+          else
+            _ul committer.urls do |url|
+              _li {_a url, href: url}
+            end
+          end
+      end
       end
     end
   end
diff --git a/www/roster/views/podlings.html.rb b/www/roster/views/podlings.html.rb
index 524fe15..5ae640d 100644
--- a/www/roster/views/podlings.html.rb
+++ b/www/roster/views/podlings.html.rb
@@ -2,6 +2,19 @@
 # List of all Podings
 #
 
+# Match name and aliases to find the entry
+def findName(podling, list)
+  if list.include?(podling.name)
+    return podling.name
+  end
+  podling.resourceAliases.each do |a|
+    if list.include? a
+      return a
+    end
+  end
+  return nil
+end
+
 _html do
   _link rel: 'stylesheet', href: "stylesheets/app.css?#{cssmtime}"
   _style %{
@@ -57,7 +70,7 @@
         end
         _ ")"
       end
-        
+
       _table.table.table_hover do
         _thead do
           _tr do
@@ -69,7 +82,9 @@
 
         _tbody do
           @podlings.sort_by {|podling| podling.name.downcase}.each do |podling|
-            status = (@attic.include?(podling.name) ? 'attic' : podling.status)
+            attic = findName(podling, @attic)
+            pmc = findName(podling, @committees)
+            status = (attic ? 'attic' : podling.status)
 
             _tr_ class: color[status] do
               _td do
@@ -77,14 +92,14 @@
                   "http://incubator.apache.org/projects/#{podling.name}.html"
               end
 
-              if @committees.include? podling.name
+              if pmc
                 _td data_sort_value: "#{podling.status} - pmc" do
-                  _a podling.status, href: "committee/#{podling.name}"
+                  _a podling.status, href: "committee/#{pmc}"
                 end
-              elsif @attic.include? podling.name
+              elsif attic
                 _td data_sort_value: "#{podling.status} - attic" do
                   _a podling.status, href:
-                    "http://attic.apache.org/projects/#{podling.name}.html"
+                    "http://attic.apache.org/projects/#{attic}.html"
                 end
               else
                 _td podling.status
diff --git a/www/roster/views/ppmc/establish.text.rb b/www/roster/views/ppmc/establish.text.rb
index 83f8d05..d8a6813 100644
--- a/www/roster/views/ppmc/establish.text.rb
+++ b/www/roster/views/ppmc/establish.text.rb
@@ -40,11 +40,6 @@
 Bylaws of the Foundation until death, resignation, retirement, removal or
 disqualification, or until a successor is appointed; and be it further
 
-RESOLVED, that the initial Apache #{podling.display_name} PMC be and hereby is
-tasked with the creation of a set of bylaws intended to encourage open
-development and increased participation in the Apache #{podling.display_name}
-Project; and be it further
-
 RESOLVED, that the Apache #{podling.display_name} Project be and hereby is
 tasked with the migration and rationalization of the Apache Incubator
 #{podling.display_name} podling; and be it further
diff --git a/www/roster/views/ppmc/mentors.js.rb b/www/roster/views/ppmc/mentors.js.rb
index a64fe40..8a5a037 100644
--- a/www/roster/views/ppmc/mentors.js.rb
+++ b/www/roster/views/ppmc/mentors.js.rb
@@ -58,11 +58,15 @@
       end
 
       if @@person.member
-        _td { _b { _a @@person.id, href: "committer/#{@@person.id}" } }
+        _td { _b { _a @@person.id, href: "committer/#{@@person.id}" }
+              _a ' (*)', href: "ppmc/#{@@ppmc.id}#crosscheck" if @@person.notSubbed and @@ppmc.analysePrivateSubs
+            }
         _td @@person.githubUsername
         _td { _b @@person.name }
       elsif @@person.name
-        _td { _a @@person.id, href: "committer/#{@@person.id}" }
+        _td { _a @@person.id, href: "committer/#{@@person.id}"
+              _a ' (*)', href: "ppmc/#{@@ppmc.id}#crosscheck" if @@person.notSubbed and @@ppmc.analysePrivateSubs
+            }
         _td @@person.githubUsername
         _td @@person.name
       else
@@ -72,6 +76,7 @@
       end
         
       _td data_ids: @@person.id do
+        # TODO: how does this become enabled?
         if @@person.selected
           if @@auth.ppmc
             unless @@ppmc.owners.include? @@person.id
@@ -80,12 +85,22 @@
                 data_target: '#confirm', data_toggle: 'modal',
                 data_confirmation: "Add #{@@person.name} as member of the " +
                   "#{@@ppmc.display_name} PPMC?"
+            else
+              unless @@ppmc.committers.include? @@person.id
+                _button.btn.btn_primary 'Add to the podling committers',
+                  data_action: 'add committer',
+                  data_target: '#confirm', data_toggle: 'modal',
+                  data_confirmation: "Add #{@@person.name} as committer of the " +
+                    "#{@@ppmc.display_name} PPMC?"
+              end
             end
           end
         elsif not @@person.name
           _span.issue 'invalid user'
         elsif not @@ppmc.owners.include? @@person.id
           _span.issue 'not on the PPMC'
+        elsif not @@ppmc.committers.include? @@person.id
+          _span.issue 'not listed as a podling committer'
         elsif not @@person.ipmc
           _span.issue 'not on the IPMC'
         elsif not @@person.icommit
diff --git a/www/roster/views/ppmcs.html.rb b/www/roster/views/ppmcs.html.rb
index 1db9a8e..addd561 100644
--- a/www/roster/views/ppmcs.html.rb
+++ b/www/roster/views/ppmcs.html.rb
@@ -13,8 +13,15 @@
         ppmc: 'ppmc/'
       }
     ) do
-      _p 'A listing of all Podling Project Management Committees (PPMCs) from the Apache Incubator.'
-      _p 'Click on column names to sort.'
+      _p 'A listing of current Podling Project Management Committees (PPMCs) from the Apache Incubator.'
+
+      _p do
+        _ 'Click on column names to sort.'
+        _{"&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;"}
+        "ABCDEFGHIJKLMNOPQRSTUVWXYZ".each_char do |c|
+          _a c, href: "ppmc/##{c}"
+        end
+      end
 
       _table.table.table_hover do
         _thead do
@@ -26,13 +33,21 @@
         end
 
         project_names = @projects.map {|project| project.name}
+        prev_letter=nil
         @ppmcs.sort_by {|ppmc| ppmc.display_name.downcase}.each do |ppmc|
-          _tr_ do
+          letter = ppmc.display_name.upcase[0]
+          if letter != prev_letter
+            options = {id: letter}
+          else
+            options = {}
+          end
+          prev_letter = letter
+          _tr_ options do
             _td do
               if project_names.include? ppmc.name
                 _a ppmc.display_name, href: "ppmc/#{ppmc.name}"
               else
-                _a.label_danger ppmc.display_name, href: "ppmc/#{ppmc.name}"
+                _a.label_danger ppmc.display_name, href: "ppmc/#{ppmc.name}", title: 'LDAP project not yet set up'
               end
             end
 
diff --git a/www/secretary/icla-lint.cgi b/www/secretary/icla-lint.cgi
index 5e10de2..316309b 100755
--- a/www/secretary/icla-lint.cgi
+++ b/www/secretary/icla-lint.cgi
@@ -1,6 +1,6 @@
 #!/usr/bin/env ruby
 
-$LOAD_PATH.unshift File.realpath(File.expand_path('../../../lib', __FILE__))
+$LOAD_PATH.unshift '/srv/whimsy/lib'
 
 require 'wunderbar/script'
 require 'ruby2js/filter/functions'
diff --git a/www/secretary/ldap-check-committers.cgi b/www/secretary/ldap-check-committers.cgi
new file mode 100755
index 0000000..79a8a8e
--- /dev/null
+++ b/www/secretary/ldap-check-committers.cgi
@@ -0,0 +1,76 @@
+#!/usr/bin/env ruby
+
+=begin
+
+LDAP people should be committers (unless login is disabled)
+
+=end
+
+$LOAD_PATH.unshift '/srv/whimsy/lib'
+
+require 'whimsy/asf'
+require 'whimsy/asf/mlist'
+require 'wunderbar'
+
+_html do
+  _style %{
+    table {border-collapse: collapse}
+    table, th, td {border: 1px solid black}
+    td {padding: 3px 6px}
+    tr:hover td {background-color: #FF8}
+    th {background-color: #a0ddf0}
+  }
+
+  _h1 'LDAP membership checks'
+
+  old = ASF::Group['committers'].memberids
+  people = ASF::Person.preload(%w(uid createTimestamp asf-banned asf-altEmail mail loginShell))
+
+  _h2 'people who are not committers (excluding nologin)'
+  
+  non_committers = people.reject { |p| p.nologin? or old.include? p.name or p.name == 'apldaptest'}
+  if non_committers.length > 0
+    _table do
+      _tr do
+        _th 'UID'
+        _th 'asf-banned?'
+        _th 'Date'
+        _th 'ICLA'
+        _th 'Subscriptions'
+        _th 'Moderates'
+      end
+      non_committers.sort_by(&:name).each do |p|
+        icla = ASF::ICLA.find_by_id(p.name)
+        _tr do
+          _td do
+            _a p.name, href: '/roster/committer/' + p.name
+          end
+          _td p.asf_banned?
+          _td p.createDate
+          if icla
+            if icla.claRef
+              _td do
+                _a icla.claRef, href: "https://svn.apache.org/repos/private/documents/iclas/#{icla.claRef}"
+              end
+            else
+              _td icla.form
+            end
+          else
+            _td 'No ICLA entry found'
+          end
+          all_mail = p.all_mail
+          _td do
+            # keep only the list names
+            _ ASF::MLIST.subscriptions(all_mail)[:subscriptions].map{|x| x[0]}
+          end
+          _td do
+            _ ASF::MLIST.moderates(all_mail)[:moderates]
+          end
+        end
+      end
+    end
+  else
+    _p 'All LDAP people entries are committers'
+  end
+
+end
\ No newline at end of file
diff --git a/www/secretary/ldap-check.cgi b/www/secretary/ldap-check.cgi
index 12a2d06..5378de8 100755
--- a/www/secretary/ldap-check.cgi
+++ b/www/secretary/ldap-check.cgi
@@ -2,11 +2,10 @@
 
 =begin
 
-Compare LDAP lists
+Compare LDAP lists (also CI)
 
-project.memberids should agree with Group.memberids (if it exixts)
-project.ownerids should agree with Committee.memberids (if it exists)
-
+PMC members should be the same as project owners (for actual PMCs)
+owners should also be members
 members and owners should also be committers
 
 The two committers groups should have the same members:
@@ -41,9 +40,9 @@
   _h2 'members and owners'
 
   _p do
-    _ 'LDAP project members must agree with corresponding (unix) group members'
+    _ 'PMC members should be project owners and vice-versa'
     _br
-    _ 'LDAP project owners must agree with corresponding committee members'
+    _ 'LDAP project owners should also be project members'
     _br
     _ 'project/podling committers must be in committers group'
     _br
@@ -53,62 +52,55 @@
   _table do
     _tr do
       _th 'Project'
-      _th 'project members - group members'
-      _th 'group members - project members'
-      _th 'project owners - committee members'
-      _th 'committee-members - project owners'
-      _th 'not in committers group'
+      _th 'PMC member but not project owner'
+      _th 'Project owner but not PMC member'
+      _th 'Project owner but not project member'
+      _th 'in project (owner or member) but not in committers group'
     end
 
     projects = ASF::Project.list
+    pmcs = ASF::Committee.pmcs
     
     projects.sort_by(&:name).each do |p|
-      po_co=[]
-      co_po=[]
-      pm_um=[]
-      um_pm=[]
+      po=p.ownerids
+      pm=p.memberids
+      po_pm = po - pm
+      cttee = ASF::Committee.find(p.name)
+      # Is this a real PMC?
+      if ASF::Committee.pmcs.include? cttee
+        isPMC = true
+        cm = cttee.roster.keys
+        cm_po = cm - po
+        po_cm = po - cm
+      else
+        isPMC = false
+        cm_po = []
+        po_cm = []
+      end
       notc=[]
-      # TODO to be removed soon
-      # Use hasLDAP? to check if the underlying ou=pmc group exists
-      if c=ASF::Committee[p.name] and c.hasLDAP? # we have PMC group 
-        po=p.ownerids
-        co=c.memberids
-        po_co=po-co
-        co_po=co-po
-        notc += po.reject {|n| old.include? n}
-        notc += co.reject {|n| old.include? n}
-      end
-      # TODO likewise, only applies to historic groups
-      if u=ASF::Group[p.name] # we have the unix group
-        pm=p.memberids
-        um=u.memberids
-        pm_um=pm-um
-        um_pm=um-pm
-        notc += pm.reject {|n| old.include? n}
-        notc += um.reject {|n| old.include? n}
-      end
-      if pm_um.size > 0 or um_pm.size > 0 or po_co.size > 0 or co_po.size > 0 or notc.size > 0
+      notc += po.reject {|n| old.include? n}
+      notc += pm.reject {|n| old.include? n}
+      if po_pm.size > 0 or cm_po.size > 0 or po_cm.size > 0 or notc.size > 0
         _tr do
           _td do
-            _a p.name, href: '/roster/committee/' + p.name
+            if isPMC
+              _a p.name, href: '/roster/committee/' + p.name
+            else
+              _a p.name, href: '/roster/ppmc/' + p.name
+            end
           end
           _td do
-            pm_um.each do |id|
+            cm_po.each do |id|
               _a id, href: '/roster/committer/' + id
             end
           end
           _td do
-            um_pm.each do |id|
+            po_cm.each do |id|
               _a id, href: '/roster/committer/' + id
             end
           end
           _td do
-            po_co.each do |id|
-              _a id, href: '/roster/committer/' + id
-            end
-          end
-          _td do
-            co_po.each do |id|
+            po_pm.each do |id|
               _a id, href: '/roster/committer/' + id
             end
           end
diff --git a/www/secretary/workbench/models/message.rb b/www/secretary/workbench/models/message.rb
index ff02dd2..f2b8408 100644
--- a/www/secretary/workbench/models/message.rb
+++ b/www/secretary/workbench/models/message.rb
@@ -348,7 +348,7 @@
     begin
       from = liberal_email_parser(from_value).display_name
     rescue Exception
-      from = from_value.sub(/\s+<.*?>$/)
+      from = from_value.sub(/\s+<.*?>$/, '')
     end
 
     # determine who should be copied on any responses
diff --git a/www/secretary/workbench/personalize.rb b/www/secretary/workbench/personalize.rb
index 9065ab0..4401210 100644
--- a/www/secretary/workbench/personalize.rb
+++ b/www/secretary/workbench/personalize.rb
@@ -6,18 +6,18 @@
   def _personalize_email(user)
     if user == 'clr'
 
-      @from = 'Craig L Russell <secretary@apache.org>'
+      @from = 'Craig L Russell <clr@apache.org>'
       @sig = %{
         -- Craig L Russell
-        Secretary, Apache Software Foundation
+        Assistant Secretary, Apache Software Foundation
       }
 
     elsif user == 'mattsicker'
 
-      @from = 'Matt Sicker <mattsicker@apache.org>'
+      @from = 'Matt Sicker <secretary@apache.org>'
       @sig = %{
         -- Matt Sicker
-        Assistant Secretary, Apache Software Foundation
+        Secretary, Apache Software Foundation
       }
 
     else
diff --git a/www/secretary/workbench/templates/mem.erb b/www/secretary/workbench/templates/mem.erb
index 3e96300..a7acc27 100644
--- a/www/secretary/workbench/templates/mem.erb
+++ b/www/secretary/workbench/templates/mem.erb
@@ -6,7 +6,14 @@
 
 You will shortly be subscribed to the members@apache.org mailing list.
 
-To verify that you have the proper rights to the foundation repository, please verify that your personal information has been properly entered: https://svn.apache.org/repos/private/foundation/members.txt and add any other information such as your personal web page and the projects that you are working on.
+To verify that you have the proper rights to the foundation repository, please check that your personal information has been properly entered:
+https://svn.apache.org/repos/private/foundation/members.txt
+You can add any other information such as your personal web page and the projects that you are working on.
+
+If you cannot get access, you can use Whimsy to check your settings:
+https://whimsy.apache.org/roster/committer/__self__
+Under 'Groups' you should see 'member'; this gives access to member-only resources.
+Note that it may take a while for all systems to pick up the new setting.
 
 If you have any questions or concerns, please feel free to reach out at members@apache.org
 
diff --git a/www/secretary/workbench/templates/pubkey.erb b/www/secretary/workbench/templates/pubkey.erb
index e4f3ef1..dc87da3 100644
--- a/www/secretary/workbench/templates/pubkey.erb
+++ b/www/secretary/workbench/templates/pubkey.erb
@@ -3,6 +3,9 @@
 We received this document but cannot verify your signature without
 your public key. Please upload your public key to pgpkeys.mit.edu.
 
+If you have trouble uploading your key, you can print, sign, date,
+scan, and email the pdf to secretary@apache.org
+
 http://www.apache.org/licenses/#submitting
 
 Warm Regards,
diff --git a/www/secretary/workbench/views/actions/check-signature.json.rb b/www/secretary/workbench/views/actions/check-signature.json.rb
index 35a0ed6..26f4dba 100644
--- a/www/secretary/workbench/views/actions/check-signature.json.rb
+++ b/www/secretary/workbench/views/actions/check-signature.json.rb
@@ -4,6 +4,10 @@
 
 ENV['GNUPGHOME'] = GNUPGHOME if GNUPGHOME
 
+#KEYSERVER = 'pgpkeys.mit.edu'
+# Perhaps also try keyserver.pgp.com
+KEYSERVERS = %w{hkps.pool.sks-keyservers.net keyserver.ubuntu.com pgpkeys.mit.edu}
+
 message = Mailbox.find(@message)
 
 begin
@@ -17,8 +21,11 @@
   gpg.untaint
 
   # run gpg verify command
-  out, err, rc = Open3.capture3 gpg, '--verify', signature.path,
-    attachment.path
+  # TODO: may need to drop the keyid-format parameter when gpg is updated as it might
+  # reduce the keyid length from the full fingerprint
+  out, err, rc = Open3.capture3 gpg,
+    '--keyid-format', 'long', # Show a longer id
+    '--verify', signature.path, attachment.path
 
   # if key is not found, fetch and try again
   if 
@@ -28,12 +35,25 @@
     # extract and fetch key
     keyid = err[/[RD]SA key (ID )?(\w+)/,2].untaint
 
-    out2, err2, rc2 = Open3.capture3 gpg, '--keyserver', 'pgpkeys.mit.edu',
-      '--recv-keys', keyid
-
+    out2, err2 = '' # needed later
+    KEYSERVERS.each do |server|
+      out2, err2, rc2 = Open3.capture3 gpg, '--keyserver', server,
+        '--debug', 'ipc', # seems to show communication with dirmngr
+        '--recv-keys', keyid
+      # for later analysis
+      Wunderbar.warn "#{gpg} --keyserver #{server} --recv-keys #{keyid} rc2=#{rc2} out2=#{out2} err2=#{err2}"
+      if rc2.exitstatus == 0 # Found the key
+        out2 = err2 = '' # Don't add download error to verify error
+        break
+      end
+    end
+  
     # run gpg verify command again
-    out, err, rc = Open3.capture3 gpg, '--verify', signature.path,
-      attachment.path
+    # TODO: may need to drop the keyid-format parameter when gpg is updated as it might
+    # reduce the keyid length from the full fingerprint
+    out, err, rc = Open3.capture3 gpg, 
+      '--keyid-format', 'long', # Show a longer id
+      '--verify', signature.path, attachment.path
 
     # if verify failed, concatenate fetch output
     if rc.exitstatus != 0
diff --git a/www/secretary/workbench/views/actions/pubkey.json.rb b/www/secretary/workbench/views/actions/pubkey.json.rb
index 9a4d81c..391b87a 100644
--- a/www/secretary/workbench/views/actions/pubkey.json.rb
+++ b/www/secretary/workbench/views/actions/pubkey.json.rb
@@ -34,7 +34,7 @@
   complete do
     mail.deliver!
 
-    _status 'request to upload public key already has been sent.'
+    _status 'request to upload public key has been sent.'
     _disposition :keep
   end
 end
diff --git a/www/secretary/workbench/views/forms/memapp.js.rb b/www/secretary/workbench/views/forms/memapp.js.rb
index 7f4022c..4dc2409 100644
--- a/www/secretary/workbench/views/forms/memapp.js.rb
+++ b/www/secretary/workbench/views/forms/memapp.js.rb
@@ -108,7 +108,7 @@
     # wire up form
     jQuery('form')[0].addEventListener('submit', self.file)
     jQuery('input[name=message]').val(window.parent.location.pathname)
-    jQuery('input[name=selected]').val(@@selected)
+    jQuery('input[name=selected]').val(decodeURIComponent(@@selected))
 
     # default email
     @email = @@headers.from
diff --git a/www/site.cgi b/www/site.cgi
index 2a3edea..eff1094 100755
--- a/www/site.cgi
+++ b/www/site.cgi
@@ -9,7 +9,7 @@
   exit
 end
 
-$LOAD_PATH.unshift File.realpath(File.expand_path('../../lib', __FILE__))
+$LOAD_PATH.unshift '/srv/whimsy/lib'
 require 'json'
 require 'net/http'
 require 'time' # for httpdate
diff --git a/www/status/monitors/public_json.rb b/www/status/monitors/public_json.rb
index c508369..16138b5 100644
--- a/www/status/monitors/public_json.rb
+++ b/www/status/monitors/public_json.rb
@@ -94,14 +94,14 @@
           $stderr.puts "Would send e-mail for #{name} #{lvl}"
           begin
             require 'mail'
-            $LOAD_PATH.unshift File.realpath(File.expand_path('../../../../lib', __FILE__))
+            $LOAD_PATH.unshift '/srv/whimsy/lib'
             require 'whimsy/asf'
             ASF::Mail.configure
             mail = Mail.new do
               from 'Public JSON job monitor  <dev@whimsical.apache.org>'
               to 'Notification List <notifications@whimsical.apache.org>'
               subject "Problem (#{lvl}) detected in #{name} job"
-              body "\nLOG: #{contents_save}\nSTATUS: #{status[name]}\n"
+              body "\nLOG:\n#{contents_save}\nSTATUS: #{status[name]}\n"
             end
             # in spite of what the docs say, this does not seem to work in the body above
             mail.charset = 'utf-8'
diff --git a/www/status/svn.cgi b/www/status/svn.cgi
index 6a4a5cf..8cc1bc9 100755
--- a/www/status/svn.cgi
+++ b/www/status/svn.cgi
@@ -1,6 +1,6 @@
 #!/usr/bin/env ruby
 
-$LOAD_PATH.unshift File.realpath(File.expand_path('../../../lib', __FILE__))
+$LOAD_PATH.unshift '/srv/whimsy/lib'
 
 #
 # SVN Repository status
diff --git a/www/technology.html b/www/technology.html
index 021e890..4fdfb97 100644
--- a/www/technology.html
+++ b/www/technology.html
@@ -113,7 +113,7 @@
       </div>
       <div class="panel-body">
         <p>
-          Copyright &copy; 2015-2018 The Apache Software Foundation, Licensed under
+          Copyright &copy; 2015-2019 The Apache Software Foundation, Licensed under
           the <a href="http://www.apache.org/licenses/LICENSE-2.0" rel="license">Apache License, Version 2.0</a>.
           | 
           <a href="https://www.apache.org/foundation/policies/privacy">Privacy Policy</a>
diff --git a/www/test/dataflow.cgi b/www/test/dataflow.cgi
index d9931a6..ed2431a 100755
--- a/www/test/dataflow.cgi
+++ b/www/test/dataflow.cgi
@@ -1,7 +1,7 @@
 #!/usr/bin/env ruby
 PAGETITLE = "Public Datafiles And Dependencies" # Wvisible:tools data
 
-$LOAD_PATH.unshift File.realpath(File.expand_path('../../../lib', __FILE__))
+$LOAD_PATH.unshift '/srv/whimsy/lib'
 require 'json'
 
 # Command line use: emit replacement for www/public/README.html 
@@ -58,7 +58,7 @@
           _ ' You can see the '
           _a 'code for this script', href: "#{GITWHIMSY}/www#{ENV['SCRIPT_NAME']}"
           _ ', the '
-          _a 'underlying data file', href: "#{GITWHIMSY}/www/#{DATAFLOWDATA}"
+          _a 'underlying data file', href: "#{GITWHIMSY}/www/test/#{DATAFLOWDATA}"
           _ ', the '
           _a 'key to this data', href: "#datakey"
           _ ', and many of the '
diff --git a/www/test/example.cgi b/www/test/example.cgi
index 64cc311..3080245 100755
--- a/www/test/example.cgi
+++ b/www/test/example.cgi
@@ -1,22 +1,88 @@
 #!/usr/bin/env ruby
 PAGETITLE = "Example Whimsy Script With Styles" # Wvisible:tools Note: PAGETITLE must be double quoted
 
-$LOAD_PATH.unshift File.realpath(File.expand_path('../../../lib', __FILE__))
+$LOAD_PATH.unshift '/srv/whimsy/lib'
 require 'json'
-require 'whimsy/asf'
+require 'yaml'
 require 'wunderbar'
 require 'wunderbar/bootstrap'
+require 'wunderbar/jquery'
+require 'wunderbar/markdown'
+require 'whimsy/asf'
+require 'whimsy/asf/forms'
+require 'whimsy/public'
 
-def get_data(defaults: {})
-  return {
-    "Sample data processing here" => "row 1",
-    "This could come from a file" => "row B"
-  }
+# Get data from live whimsy.a.o/public directory
+def get_public_data()
+  return Public.getJSON('public_ldap_authgroups.json')
 end
 
+# Get data from a Subversion directory
+# See /repository.yml for list of auto-updated dirs
+def get_svn_data()
+  dir = ASF::SVN['comdevtalks']
+  filename = 'README.yaml'
+  data = YAML.load(File.read(File.join(dir, filename).untaint))
+  return data['title']
+end
+
+# Gather some data beforehand, if you like, but:
+# Note runtime errors here just write to the log, not to user's browser
+talktitle = get_svn_data()
+
+# Example of handling POST forms cleanly
+def emit_form(title, prev_data)
+  _whimsy_panel("#{title}", style: 'panel-success') do
+    _form.form_horizontal method: 'post' do
+      _div.form_group do
+        _label.col_sm_offset_3.col_sm_9.strong.text_left 'Example Form Section'
+      end
+      field = 'text1'
+      _whimsy_forms_input(label: 'Example Text Field', name: field, id: field,
+        value: prev_data[field], helptext: 'Enter some text, keep it polite!'
+      )
+      field = 'listbox'
+      _whimsy_forms_select(label: 'Select Some Values', name: field,
+        multiple: true, values: prev_data[field],
+        options: ['another value', 'yet another value'],
+        icon: 'glyphicon-time', iconlabel: 'clock', 
+        helptext: 'Select as many values as ya like!'
+      )
+      field = 'text2'
+      _whimsy_forms_input(label: 'Another Text Field', name: field, id: field,
+        value: prev_data[field], helptext: 'Pretty boring form example, huh?'
+      )
+      _div.col_sm_offset_3.col_sm_9 do
+        _input.btn.btn_default type: 'submit', value: 'PUSH ME!'
+      end
+    end
+  end
+end
+
+# Validation as needed within the script
+def validate_form(formdata: {})
+  return true # TODO: Futureuse
+end
+
+# Handle submission (checkout user's apacheid.json, write form data, checkin file)
+# @return true if we think it succeeded; false in all other cases
+def send_form(formdata: {})
+  # Example that uses SVN to update an existing file: members/mentor-update.cgi
+  _p class: 'system' do
+    _ 'If this were a real send_form() it would do something with your data:'
+    _br
+    formdata.each do |k,v|
+      _ "#{k} = #{v.inspect}"
+      _br
+    end
+  end
+  return true
+end
+
+# Produce HTML
 _html do
-  _body? do
-    _whimsy_body(
+  _body? do # The ? traps errors inside this block
+    _whimsy_body( # This emits the entire page shell: header, navbar, basic styles, footer
       title: PAGETITLE,
       subtitle: 'About This Example Script',
       relatedtitle: 'More Useful Links',
@@ -33,6 +99,18 @@
           Any related whimsy or other (projects.a.o, etc.) links should be in the related: listing on the top right to help users find other useful things.
           This provides a consistent user experience.
         }
+        _p "You can output data previously processed as well like: #{talktitle}"
+        _ul.list_inline do
+          _li do
+            _a 'example-table', href: '#example-table'
+          end
+          _li do
+            _a 'example-accordion', href: '#example-accordion'
+          end
+          _li do
+            _a 'example-form', href: '#example-form'
+          end
+        end
       },
       breadcrumbs: {
         dataflow: '/test/dataflow.cgi',
@@ -40,6 +118,7 @@
       }
     ) do
       # IF YOUR SCRIPT EMITS A LARGE TABLE
+      _div id: 'example-table'
       _whimsy_panel_table(
         title: "Data Table H2 Title Goes Here",
         helpblock: -> {
@@ -49,7 +128,7 @@
         # Gather or process your data **here**, so if an error is raised, the _body? 
         #   scope will trap it - and will then display the above help information 
         #   to the user before emitting a polite error traceback.
-        datums = get_data()
+        datums = {'one' => 1, 'two' => 2 }
         _table.table.table_hover.table_striped do
           _thead_ do
             _tr do
@@ -71,6 +150,7 @@
           end
         end
       end
+
       # IF YOUR SCRIPT ONLY EMITS SIMPLE DATA
       _h2 "Simple Data Can Just Use A List"
       _ul do
@@ -78,6 +158,53 @@
           _li "This is row number #{row}."
         end
       end
+
+      # NIFTY ACCORDION EXPAND-O LISTING: the _whimsy_accordion_item does most of the work
+      _h2 id: 'example-accordion' do
+        _ 'Lists of Complex Data Can Use An Accordion'
+      end 
+      accordionid = 'accordion'
+      officers = get_public_data()
+      _div.panel_group id: accordionid, role: 'tablist', aria_multiselectable: 'true' do
+        officers['auth'].each_with_index do |(listname, rosters), n|
+          _whimsy_accordion_item(listid: accordionid, itemid: listname, itemtitle: "#{listname}", n: n, itemclass: 'panel-primary') do
+            _ul do
+              rosters['roster'].each do |usr|
+                _li usr
+              end
+            end
+          end
+        end
+      end
+      
+      # IF YOU WANT TO DISPLAY A FORM and handle the POST
+      _div id: 'example-form'
+      if _.post?
+        # Use magic _. callouts to CGI class to gather POST data into submission hash
+        submission = {}
+        keyz = _.keys
+        keyz.each do |k|
+          submission[k] = _.params[k] # Always as ['val'] or ['one', 'two', ...]
+        end
+        if validate_form(formdata: submission)
+          if send_form(formdata: submission)
+            _p.lead "Thanks for Submitting This Form!"
+            _p do 
+              _ "The send_form method would have done any procesing needed with the data, after calling validate_data."
+            end
+          else
+            _div.alert.alert_warning role: 'alert' do
+              _p "SORRY! Your submitted form data failed send_form, please try again."
+            end
+          end
+        else
+          _div.alert.alert_danger role: 'alert' do
+            _p "SORRY! Your submitted form data failed validate_form, please try again."
+          end
+        end
+      else # if _.post?
+        emit_form('Form Title Here', officers)
+      end
     end
   end
 end
diff --git a/www/treasurer/bill-upload.cgi b/www/treasurer/bill-upload.cgi
index a4b0a87..afe5ca6 100755
--- a/www/treasurer/bill-upload.cgi
+++ b/www/treasurer/bill-upload.cgi
@@ -1,6 +1,6 @@
 #!/usr/bin/env ruby
 PAGETITLE = "Apache Treasurer Bill Upload" # Wvisible:treasurer
-$LOAD_PATH.unshift File.realpath(File.expand_path('../../../lib', __FILE__))
+$LOAD_PATH.unshift '/srv/whimsy/lib'
 require 'wunderbar'
 require 'wunderbar/bootstrap'
 require 'wunderbar/jquery'