@bongos/core 1.19.374 → 1.19.376

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/.bongos-core.json +99 -54
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +4 -0
  4. package/clients/bongos-client/index.cjs +4 -0
  5. package/clients/bongos-client/index.d.ts +9 -1
  6. package/clients/bongos-client/index.mjs +4 -0
  7. package/docs/adr/0194-joinability-one-door-decision.md +42 -0
  8. package/docs/adr/README.md +1 -0
  9. package/docs/api/openapi.json +158 -5
  10. package/docs/api-reference.md +6 -4
  11. package/docs/copy-inventory.md +373 -356
  12. package/docs/copy-registry.json +646 -452
  13. package/migrations/core_227_task_created_by.sql +83 -0
  14. package/modules/dev-box/app/src/vendor/bongos-client.cjs +4 -0
  15. package/modules/government/catalog.js +1 -1
  16. package/modules/hall-ui/public/builders.js +5 -3
  17. package/modules/hall-ui/public/goals-lib.js +5 -5
  18. package/modules/hall-ui/public/goals-page.js +54 -23
  19. package/modules/hall-ui/public/goals-render.js +15 -0
  20. package/modules/hall-ui/public/hall-render.js +63 -22
  21. package/modules/hall-ui/public/my-projects.js +6 -1
  22. package/modules/hall-ui/public/task-lib.js +29 -0
  23. package/modules/hall-ui/public/watch.css +8 -0
  24. package/modules/hall-ui/public/watch.js +56 -3
  25. package/modules/hall-ui/public/work.js +117 -5
  26. package/modules/ideas/inbox.js +4 -0
  27. package/modules/lifecycle/db-tasks.js +61 -5
  28. package/modules/lifecycle/goal-rollup.js +118 -0
  29. package/modules/lifecycle/routes/goals.js +26 -0
  30. package/modules/lifecycle/routes/task-write-routes.js +3 -0
  31. package/modules/lifecycle/routes/tasks.js +47 -1
  32. package/modules/onboarding/module.json +1 -1
  33. package/modules/onboarding/routes/access-requests.js +87 -1
  34. package/modules/platform-identity/routes/my-projects.js +35 -9
  35. package/package-lock.json +2 -2
  36. package/package.json +1 -1
  37. package/public-landing/projects.html +169 -3
  38. package/scripts/gds/login.js +24 -3
  39. package/src/bongos/auth-admission.js +50 -5
  40. package/src/bongos/auth.js +2 -1
  41. package/src/bongos/routes/auth.js +15 -0
  42. package/src/branding.js +3 -2
  43. package/src/module-api.js +22 -1
  44. package/tests/access_requests_invite.mjs +165 -0
  45. package/tests/auth_admission_gate.mjs +42 -0
  46. package/tests/goal_sort_staleness.mjs +286 -0
  47. package/tests/goal_subtitle.mjs +6 -1
  48. package/tests/goals_lib.mjs +16 -4
  49. package/tests/join_relay_outcome.mjs +61 -0
  50. package/tests/joinability_apply.mjs +72 -0
  51. package/tests/joinability_invite_only.mjs +192 -0
  52. package/tests/joinability_open.mjs +89 -0
  53. package/tests/module_api.mjs +1 -0
  54. package/tests/projects_hub.mjs +76 -2
  55. package/tests/projects_hub_pre_uat.mjs +4 -2
  56. package/tests/public_landing_projects.mjs +10 -3
@@ -0,0 +1,83 @@
1
+ -- core_227_task_created_by.sql — a task remembers who created it (task 1003278,
2
+ -- goal 1000072, criterion legibility-sort-and-staleness).
3
+ --
4
+ -- THE GAP. "You can find your own work" was impossible on the task tier: tasks
5
+ -- carried source/source_ref (which PIPELINE filed it) and shipped_by (who landed
6
+ -- it), but nothing said which PERSON created it. A goal has had created_by since
7
+ -- migration 160; a task had nothing, so the owner could not ask the board "what
8
+ -- did I create" — one of the exact questions goal 1000072 exists to answer.
9
+ --
10
+ -- NULLABLE, NO BACKFILL, ON PURPOSE. History is honestly unattributable: the
11
+ -- ~1,357 existing BONGOS-V1 tasks were created before this column, and guessing
12
+ -- (from shipped_by, from a claim, from the source string) would write confident
13
+ -- lies into an attribution column. NULL = created before attribution existed, or
14
+ -- by a system path with no builder in the request — the same convention as
15
+ -- goals.created_by (migration 160: "NULL = system-attributed"). The UI's filter
16
+ -- gets a 'system / unattributed' bucket, exactly like the goals owner filter.
17
+ --
18
+ -- Additive + forward-safe: every existing reader either SELECTs an explicit
19
+ -- column list or `t.*` (which tolerates a new column); nothing NOT NULL, nothing
20
+ -- renamed (tests/migrations_forward_safe.mjs).
21
+ --
22
+ -- THE VIEWS MUST BE REBUILT WITH IT. A Postgres view created with `SELECT t.*`
23
+ -- pins its column list at CREATE VIEW time — core_190's own header documents six
24
+ -- columns that silently never reached the claim feed this way, and the Work
25
+ -- Board's Creator filter reads created_by off exactly that feed. So this
26
+ -- migration re-runs core_190's DROP+CREATE verbatim (no WHERE/CASE change), the
27
+ -- same way core_190 itself repaired the 044-era views.
28
+
29
+ BEGIN;
30
+
31
+ ALTER TABLE tasks
32
+ ADD COLUMN IF NOT EXISTS created_by bigint REFERENCES builders(id);
33
+
34
+ COMMENT ON COLUMN tasks.created_by IS
35
+ 'The builder who created this task (task 1003278). NULL = created before attribution existed, or by a system path with no builder in the request — never backfilled, because a guessed attribution is a confident lie. The goals.created_by convention (migration 160).';
36
+
37
+ CREATE INDEX IF NOT EXISTS idx_tasks_created_by ON tasks (created_by);
38
+
39
+ -- Rebuild the two t.* views so created_by is projected (bodies identical to
40
+ -- core_190 — the wildcard re-expands to every current column).
41
+
42
+ DROP VIEW IF EXISTS claimable_tasks;
43
+ CREATE VIEW claimable_tasks AS
44
+ SELECT t.*,
45
+ pms_calibrated_minutes(t.est_minutes, t.kind) AS est_minutes_calibrated
46
+ FROM tasks t
47
+ WHERE t.status = 'ready'
48
+ AND t.title NOT LIKE '\_\_smoke\_\_%' ESCAPE '\'
49
+ AND NOT EXISTS (
50
+ SELECT 1 FROM task_blockers tb WHERE tb.task_id = t.id
51
+ )
52
+ AND NOT EXISTS (
53
+ SELECT 1 FROM claims c
54
+ JOIN tasks t2 ON t2.id = c.task_id
55
+ WHERE c.released_at IS NULL
56
+ AND t2.touches && t.touches
57
+ );
58
+
59
+ DROP VIEW IF EXISTS tasks_session_fit;
60
+ CREATE VIEW tasks_session_fit AS
61
+ SELECT t.*,
62
+ CASE
63
+ WHEN t.automation_tag = 'overnight-safe' AND COALESCE(t.manual_degree, 5) <= 2
64
+ AND NOT EXISTS (SELECT 1 FROM task_blockers tb WHERE tb.task_id = t.id)
65
+ THEN ARRAY['autonomous','check-in','active']
66
+ WHEN t.automation_tag = 'live-only' OR COALESCE(t.manual_degree, 5) >= 4
67
+ THEN ARRAY['active']
68
+ ELSE ARRAY['check-in','active']
69
+ END AS fits_session_modes,
70
+ pms_calibrated_minutes(t.est_minutes, t.kind) AS est_minutes_calibrated,
71
+ CASE
72
+ WHEN t.credits_reward > 0
73
+ AND pms_calibrated_minutes(t.est_minutes, t.kind) IS NOT NULL
74
+ AND pms_calibrated_minutes(t.est_minutes, t.kind) > 0
75
+ THEN ROUND(t.credits_reward::numeric / pms_calibrated_minutes(t.est_minutes, t.kind), 3)
76
+ ELSE NULL
77
+ END AS credits_per_minute
78
+ FROM tasks t;
79
+
80
+ COMMIT;
81
+
82
+ INSERT INTO schema_migrations (version) VALUES ('core_227_task_created_by')
83
+ ON CONFLICT (version) DO NOTHING;
@@ -90,6 +90,8 @@ function createClient(opts = {}) {
90
90
  postAccessRequests: (args) => request("POST", "/access-requests", { hasBody: true }, args),
91
91
  // PATCH /access-requests/{id} — rank: archon — PATCH /access-requests/:id
92
92
  patchAccessRequestsId: (args) => request("PATCH", "/access-requests/{id}", { hasBody: true }, args),
93
+ // POST /access-requests/invite — rank: archon — POST /access-requests/invite
94
+ postAccessRequestsInvite: (args) => request("POST", "/access-requests/invite", { hasBody: true }, args),
93
95
  // GET /access-requests/status — rank: public — GET /access-requests/status
94
96
  getAccessRequestsStatus: (args) => request("GET", "/access-requests/status", { hasBody: false }, args),
95
97
  },
@@ -364,6 +366,8 @@ function createClient(opts = {}) {
364
366
  getGoalsInbox: (args) => request("GET", "/goals/inbox", { hasBody: false }, args),
365
367
  // POST /goals/reorder — rank: metic+archon — POST /goals/reorder
366
368
  postGoalsReorder: (args) => request("POST", "/goals/reorder", { hasBody: true }, args),
369
+ // GET /goals/rollups — rank: any-builder — GET /goals/rollups
370
+ getGoalsRollups: (args) => request("GET", "/goals/rollups", { hasBody: false }, args),
367
371
  // GET /goals/suggestions — rank: any-builder — GET /goals/suggestions
368
372
  getGoalsSuggestions: (args) => request("GET", "/goals/suggestions", { hasBody: false }, args),
369
373
  },
@@ -127,7 +127,7 @@ const PERMISSIONS = [
127
127
  // one of them would make this delegation self-reversible.
128
128
  { key: 'builder.status.set', system: false, floor: 'archon', guards: 'PATCH /builders/:id/status — offboard / (de)activate' },
129
129
  { key: 'auth.session.revoke', system: false, floor: 'archon', guards: 'POST /auth/revoke, /auth/revoke-all' },
130
- { key: 'access_request.review', system: false, floor: 'archon', guards: 'GET/PATCH /access-requests/:id — admit a builder' },
130
+ { key: 'access_request.review', system: false, floor: 'archon', guards: 'GET/PATCH /access-requests/:id, POST /access-requests/invite — admit a builder' },
131
131
 
132
132
  // ── operational — floor metic (the working rank's review/triage/authoring) ────
133
133
  { key: 'idea.triage', system: false, floor: 'metic', guards: 'PATCH /inbox/:id' },
@@ -79,9 +79,11 @@
79
79
  let code = null;
80
80
  try {
81
81
  const j = res.data;
82
- // Prefer the human-facing message; fall back to the machine code.
83
- detail = j.message || j.detail || j.error || detail;
84
- code = j.error || null;
82
+ // Prefer the human-facing message; fall back to the machine code. The
83
+ // envelope (ADR 0116) nests both under `error`; older handlers answer flat.
84
+ const env = j.error && typeof j.error === 'object' ? j.error : null;
85
+ detail = (env && env.message) || j.message || j.detail || (typeof j.error === 'string' ? j.error : detail);
86
+ code = (env && env.code) || (typeof j.error === 'string' ? j.error : null);
85
87
  } catch (_) { /* keep default */ }
86
88
  const err = new Error(detail);
87
89
  err.status = res.status;
@@ -84,11 +84,11 @@
84
84
  return false;
85
85
  }
86
86
 
87
- // ms helpers for the goal ordering (task 1002965): updated_at moves on every
88
- // task write, shipped_at folded in because a ship path can stamp it without
89
- // touching updated_at.
90
- const msOf = (iso) => { const t = iso ? Date.parse(iso) : NaN; return Number.isNaN(t) ? 0 : t; };
91
- const taskActivityMs = (t) => Math.max(msOf(t.updated_at), msOf(t.shipped_at));
87
+ // ms helpers for the goal ordering (task 1002965). The DEFINITIONS moved to
88
+ // task-lib.js in task 1003278 so the Tasks board and the Goals page read ONE
89
+ // meaning of "touched"; re-exported here so this lib's API is unchanged.
90
+ // goals.html loads task-lib.js before this file (the render-family order).
91
+ const { msOf, taskActivityMs } = window.OTBTaskLib;
92
92
 
93
93
  // task 1003224 — on a LIST, a goal's subtitle is a TOOLTIP, never a second
94
94
  // visible line. The board's job there is to let someone find a goal by name;
@@ -144,37 +144,60 @@
144
144
  // Array#sort is stable in every browser this hall supports — so goals sharing
145
145
  // a timestamp (or both missing one) keep their priority order as the tiebreak.
146
146
 
147
- // Fetch every task for the versions the goals span (one request each, with dep
148
- // summaries) and fold into per-goal rollups. Failure-tolerant: an older server
149
- // that doesn't honor include=deps just yields empty rollups and the list falls
150
- // back to plain cards. Tasks come back priority-ordered, so the first `ready`
151
- // one is the goal's best next pick.
147
+ // Per-goal rollups: readiness counters, the last-activity timestamp, and the
148
+ // craft set. Since task 1003278 the SERVER computes these (GET /goals/rollups),
149
+ // one small row per goal.
150
+ //
151
+ // WHY IT MOVED, in one number. This used to fetch every task in every version
152
+ // the board spans — `GET /tasks?version=…&include=deps` — and reduce them here.
153
+ // On the real corpus that single request measured **5,229,455 bytes for ~1,400
154
+ // tasks** (2026-08-25) to produce about forty counter objects. The criterion
155
+ // this page serves says it plainly: "~1,357 tasks on BONGOS-V1 alone. A view
156
+ // that does not survive that number is not a solution." It did not survive it;
157
+ // it just did not visibly fall over.
158
+ //
159
+ // The buckets, the activity clock and the next-ready pick are unchanged — the
160
+ // SQL mirrors goals-lib.js `readinessOf` exactly, and a test holds the two
161
+ // against each other, because a divergence would show a person different
162
+ // numbers depending on which surface they opened.
163
+ //
164
+ // Failure-tolerant, and deliberately NOT falling back to the old whole-version
165
+ // fetch: a "fallback" that quietly re-downloads 5 MB is the bug wearing a
166
+ // safety belt. An older server yields empty rollups and the cards render plain.
152
167
  async function loadRollups() {
153
168
  const versions = [...new Set(state.goals.map((g) => g.version_id).filter(Boolean))];
154
169
  const lists = await Promise.all(versions.map((v) =>
155
- getJSON(`${API}/tasks?version=${encodeURIComponent(v)}&include=deps`)
156
- .then((r) => r.tasks || []).catch(() => [])));
170
+ getJSON(`${API}/goals/rollups?version=${encodeURIComponent(v)}`)
171
+ .then((r) => r.rollups || []).catch(() => [])));
157
172
  const roll = new Map();
158
173
  const activity = new Map();
159
- for (const t of lists.flat()) {
160
- if (t.goal_id == null) continue;
161
- const key = String(t.goal_id);
162
- let r = roll.get(key);
163
- if (!r) { r = { done: 0, active: 0, ready: 0, blocked: 0, backlog: 0, cut: 0, total: 0, nextReady: null }; roll.set(key, r); }
164
- const bucket = readinessOf(t);
165
- r[bucket] += 1;
166
- if (bucket !== 'cut') r.total += 1;
167
- if (bucket === 'ready' && !r.nextReady) r.nextReady = t;
168
- // task 1002965 — ANY task activity counts (owner call), not just ships: a
169
- // goal being worked right now should read as active before anything lands.
170
- // updated_at moves on every task write (claim, status flip, edit), so it
171
- // dominates in practice; shipped_at is folded in because a task can be
172
- // shipped by a path that stamps it without touching updated_at.
173
- const ts = taskActivityMs(t);
174
- if (ts && ts > (activity.get(key) || 0)) activity.set(key, ts);
174
+ const disciplines = new Map(); // task 1003278 — goalId -> Set(craft)
175
+ for (const row of lists.flat()) {
176
+ const key = String(row.goal_id);
177
+ roll.set(key, {
178
+ done: row.done || 0,
179
+ active: row.active || 0,
180
+ ready: row.ready || 0,
181
+ blocked: row.blocked || 0,
182
+ backlog: row.backlog || 0,
183
+ cut: row.cut || 0,
184
+ total: row.total || 0,
185
+ // The row shape the renderers already expect: `nextReady` is a task-like
186
+ // object with an id and a title, and nothing else was ever read off it.
187
+ nextReady: row.next_ready_id ? { id: row.next_ready_id, title: row.next_ready_title } : null,
188
+ });
189
+ const ts = msOf(row.last_activity_at);
190
+ if (ts) activity.set(key, ts);
191
+ // The goal's CRAFT set. "Role" here is the craft axis ADR 0174 reserves the
192
+ // word for (engineer | artist | ideator | ui), never a goal membership —
193
+ // that is `membership_kind` (ADR 0177). A goal has no discipline column and
194
+ // must not get one: its craft set is what its tasks carry, which is why the
195
+ // server aggregates it rather than the goal row storing it.
196
+ disciplines.set(key, new Set(row.disciplines || []));
175
197
  }
176
198
  state.rollups = roll;
177
199
  state.activity = activity;
200
+ state.disciplines = disciplines;
178
201
  }
179
202
 
180
203
  /* ---------------- list view ---------------- */
@@ -893,6 +916,14 @@
893
916
  // task 1003276 — filled from GET /work-categories after boot
894
917
  // (populateCategoryFilter), the populateOwnerFilter pattern.
895
918
  { id: 'f-goal-category', label: 'Category', options: [{ value: '', label: '— any —' }] },
919
+ // task 1003278 — the craft axis (ADR 0174 reserves "role" for the crafts;
920
+ // a goal membership is a membership_kind, ADR 0177). Values are the
921
+ // disciplines a goal's tasks carry, derived in loadRollups. Staleness
922
+ // buckets are the shared STALE_CHOICES from task-lib.js so this page and
923
+ // the Tasks board offer the same lenses.
924
+ { id: 'f-goal-role', label: 'Craft', options: [ // ADR 0174: "role" means a craft
925
+ { value: '', label: '— any —' }, 'engineer', 'artist', 'ideator', 'ui'] },
926
+ { id: 'f-goal-stale', label: 'Age', options: window.OTBTaskLib.STALE_CHOICES },
896
927
  { id: 'f-goal-order',
897
928
  label: 'Sort',
898
929
  value: GOAL_SORTS[savedGoalSort] ? savedGoalSort : undefined,
@@ -60,6 +60,9 @@
60
60
  newest: { label: 'newest first', cmp: (a, b) => goalCreatedMs(b) - goalCreatedMs(a), stamp: (g) => ({ label: 'created', ms: goalCreatedMs(g) }) },
61
61
  oldest: { label: 'oldest first', cmp: (a, b) => goalCreatedMs(a) - goalCreatedMs(b), stamp: (g) => ({ label: 'created', ms: goalCreatedMs(g) }) },
62
62
  active: { label: 'recently active', cmp: (a, b) => goalActivityMs(b) - goalActivityMs(a), stamp: (g) => ({ label: 'last work', ms: goalActivityMs(g) }) },
63
+ // task 1003278 — the rot view: least-recently-touched first, on the SAME
64
+ // clock as 'recently active' so the two can never disagree.
65
+ stale: { label: 'stalest first', cmp: (a, b) => goalActivityMs(a) - goalActivityMs(b), stamp: (g) => ({ label: 'last work', ms: goalActivityMs(g) }) },
63
66
  };
64
67
  const currentGoalSort = () => GOAL_SORTS[$('#f-goal-order')?.value] || GOAL_SORTS.priority;
65
68
  function sortGoals(rows) {
@@ -93,6 +96,16 @@
93
96
  || (owner === 'none' ? g.created_by == null : String(g.created_by) === owner);
94
97
  const matchesCategory = (g) => !cat
95
98
  || (cat === 'none' ? !g.category : (g.category?.slug === cat));
99
+ // task 1003278 — role + staleness. A goal's role is the discipline set its
100
+ // tasks carry (loadRollups derives it from the per-version task fetch); its
101
+ // clock is the same activity timestamp the 'recently active' sort reads, so
102
+ // sorting and filtering can never disagree about what "touched" means.
103
+ const role = $('#f-goal-role')?.value || '';
104
+ const staleDays = $('#f-goal-stale')?.value || '';
105
+ const { isStale } = window.OTBTaskLib;
106
+ const matchesRole = (g) => !role
107
+ || !!(state.disciplines && state.disciplines.get(String(g.id))?.has(role));
108
+ const matchesStale = (g) => !staleDays || isStale(goalActivityMs(g), staleDays);
96
109
  const myId = selfId();
97
110
  const iAmMember = (g) => {
98
111
  const d = state.details.get(String(g.id));
@@ -104,6 +117,8 @@
104
117
  matchesSearch(g) &&
105
118
  matchesOwner(g) &&
106
119
  matchesCategory(g) &&
120
+ matchesRole(g) &&
121
+ matchesStale(g) &&
107
122
  (!mineOnly || iAmMember(g)));
108
123
  }
109
124
  function goalStampHtml(g) {
@@ -238,23 +238,43 @@
238
238
  else standingRail = window.OTBKit.makeSectionRail({ mount: rail, items, mode: 'scrollspy' });
239
239
  }
240
240
  }
241
- function renderLanding() {
242
- // task 1723: collapse the shell nav to the signed-out minimum (Home + a
243
- // Sign-in button). Cosmetic — the server gates every page regardless.
244
- window.OTBShell?.applyAccess({ authed: false });
245
-
246
- const welcome = $('#welcome-line');
247
- if (welcome) welcome.textContent = '';
241
+ // The project's door, as the platform pushed it into this instance's pack
242
+ // (window.__BRANDING__.project.joinability, task 1003044): what a stranger is
243
+ // told to do next depends on it. An older pack reports none → 'apply', today's
244
+ // behaviour. Cosmetic — the server refuses on its own terms either way.
245
+ function joinabilityMode() {
246
+ const p = window.__BRANDING__ && window.__BRANDING__.project;
247
+ const v = p && p.joinability;
248
+ return v === 'open' || v === 'invite_only' ? v : 'apply';
249
+ }
248
250
 
249
- const landing = $('#landing-root');
250
- if (landing) {
251
- landing.hidden = false;
252
- landing.innerHTML = `
253
- <div class="scroll landing">
254
- <h2 class="scroll__h">Welcome</h2>
251
+ // One landing per door: open → sign in and you're in; invite_only → nothing to
252
+ // apply for, sign in once invited; apply → the request form.
253
+ function landingDoorHtml(mode) {
254
+ const hub = `This is the builders' hub for ${brandWorld()} — where builders claim work, earn ${curLabel()}, and track their standing.`;
255
+ const admitter = escapeHtml(rankLabel('archon'));
256
+ const signIn = `<p class="landing__cta"><a class="landing__btn" href="${API}/auth/web/start">Sign in with GitHub</a></p>`;
257
+ if (mode === 'open') {
258
+ return `
259
+ <p class="landing__lede">${hub} Anyone with a GitHub account can join: sign in and you're in, starting at the newcomer rank.</p>
260
+ <div class="landing__request" id="landing-request">
261
+ <h3 class="landing__request-h">New here — sign in to join</h3>
262
+ <p class="landing__request-lede">No request and no invite needed. Sign in with GitHub, and a step-by-step guide takes you the rest of the way.</p>
263
+ ${signIn}
264
+ </div>`;
265
+ }
266
+ if (mode === 'invite_only') {
267
+ return `
268
+ <p class="landing__lede">${hub} This project is invite-only: the ${admitter} invites each builder, and there is nothing to apply for here.</p>
269
+ <div class="landing__request" id="landing-request">
270
+ <h3 class="landing__request-h">Invited? Sign in</h3>
271
+ <p class="landing__request-lede">If the ${admitter} has invited you, sign in with GitHub and you're in. If not, ask them for an invite — this project doesn't take requests.</p>
272
+ ${signIn}
273
+ </div>`;
274
+ }
275
+ return `
255
276
  <p class="landing__lede">
256
- This is the builders' hub for ${brandWorld()} — where builders claim
257
- work, earn ${curLabel()}, and track their standing. Access is
277
+ ${hub} Access is
258
278
  invite-based: an Archon approves each new builder. To begin, request
259
279
  access below.
260
280
  </p>
@@ -290,7 +310,24 @@
290
310
  <button type="submit" class="landing__btn landing__btn--request" id="ar-submit">Request access</button>
291
311
  <p class="landing__request-status" id="ar-status" aria-live="polite"></p>
292
312
  </form>
293
- </div>
313
+ </div>`;
314
+ }
315
+
316
+ function renderLanding() {
317
+ // task 1723: collapse the shell nav to the signed-out minimum (Home + a
318
+ // Sign-in button). Cosmetic — the server gates every page regardless.
319
+ window.OTBShell?.applyAccess({ authed: false });
320
+
321
+ const welcome = $('#welcome-line');
322
+ if (welcome) welcome.textContent = '';
323
+
324
+ const landing = $('#landing-root');
325
+ if (landing) {
326
+ landing.hidden = false;
327
+ landing.innerHTML = `
328
+ <div class="scroll landing">
329
+ <h2 class="scroll__h">Welcome</h2>
330
+ ${landingDoorHtml(joinabilityMode())}
294
331
 
295
332
  <p class="landing__signin-note">
296
333
  Already a member? <a href="${API}/auth/web/start">Sign in with GitHub</a>.
@@ -346,15 +383,19 @@
346
383
  </p>`;
347
384
  }
348
385
  } catch (err) {
349
- // Already known to the city — they don't need a request, they need to
350
- // sign in. Both the "already a builder" and "already approved" cases
351
- // collapse the form into a sign-in prompt rather than a red error.
352
- if (err && (err.code === 'already_member' || err.code === 'already_approved')) {
386
+ // The door's own answers — already a builder, already approved, or a
387
+ // project that takes no requests (invite-only) / needs none (open) —
388
+ // collapse the form into what to do instead: sign in. Never a red error.
389
+ const code = err && err.code;
390
+ if (code === 'already_member' || code === 'already_approved' || code === 'invite_only' || code === 'open_enrollment') {
353
391
  const req = $('#landing-request');
354
392
  if (req) {
393
+ const heading = code === 'invite_only' ? 'This project is invite-only'
394
+ : code === 'open_enrollment' ? 'No request needed'
395
+ : 'You already have access ✓';
355
396
  req.innerHTML = `
356
- <h3 class="landing__request-h">You already have access ✓</h3>
357
- <p class="landing__request-lede">${escapeHtml(err.message || 'You already have access.')}</p>
397
+ <h3 class="landing__request-h">${heading}</h3>
398
+ <p class="landing__request-lede">${escapeHtml(err.message || 'Sign in with GitHub to continue.')}</p>
358
399
  <p class="landing__cta">
359
400
  <a class="landing__btn" href="${API}/auth/web/start">Sign in with GitHub</a>
360
401
  </p>`;
@@ -130,7 +130,12 @@
130
130
  .then(function (r) { return r.json().then(function (b) { return { ok: r.ok, body: b }; }); })
131
131
  .then(function (res) {
132
132
  joinBtn.disabled = false;
133
- if (res.ok && res.body && res.body.message) { joinNote.textContent = res.body.message; joinInput.value = ''; load(); }
133
+ if (res.ok && res.body && res.body.message) {
134
+ joinNote.textContent = res.body.message;
135
+ /* a door refusal rides a 200 (task 1003044): keep the address, and don't repaint as if a request went out */
136
+ var refused = res.body.status === 'invite_only' || res.body.status === 'open_enrollment' || res.body.status === 'members_only';
137
+ if (!refused) { joinInput.value = ''; load(); }
138
+ }
134
139
  else if (res.body && res.body.error && res.body.error.code === 'unknown_project') { joinNote.textContent = 'That project isn’t on the platform.'; }
135
140
  else { joinNote.textContent = 'Could not request access right now.'; }
136
141
  })
@@ -108,6 +108,34 @@
108
108
  // case leaked a machine code into the Goals page UI. Adding a server-side reason
109
109
  // without adding it here degrades quietly rather than loudly, so keep this in
110
110
  // step with the claim route's refusal codes.
111
+ // ---- staleness (task 1003278, criterion legibility-sort-and-staleness) ----
112
+ // ONE definition of "touched", shared by both tiers: updated_at moves on every
113
+ // task write (claim, status flip, edit); shipped_at is folded in because a ship
114
+ // path can stamp it without touching updated_at (the task-1002965 owner call,
115
+ // previously private to goals-lib.js — moved HERE so the Tasks board and the
116
+ // Goals page cannot disagree about what "untouched" means).
117
+ const msOf = (iso) => { const t = iso ? Date.parse(iso) : NaN; return Number.isNaN(t) ? 0 : t; };
118
+ const taskActivityMs = (t) => Math.max(msOf(t.updated_at), msOf(t.shipped_at));
119
+
120
+ // The staleness buckets both boards offer. A CLIENT-side vocabulary, not the
121
+ // project's configured stale timer (task 1003279 wires that into the sweep);
122
+ // these are lenses for a person browsing, and the sweep is the enforcement.
123
+ const STALE_CHOICES = [
124
+ { value: '', label: '— any age —' },
125
+ { value: '14', label: 'untouched 14+ days' },
126
+ { value: '30', label: 'untouched 30+ days' },
127
+ { value: '60', label: 'untouched 60+ days' },
128
+ ];
129
+ // Does this activity timestamp fall past the chosen bucket? '' = no filter.
130
+ // 0 (no readable timestamp at all) is treated as STALE, not fresh — a row
131
+ // nobody can date is exactly what a rot-hunt must not hide.
132
+ function isStale(activityMs, days, nowMs) {
133
+ const d = Number(days);
134
+ if (!d) return true;
135
+ const now = Number.isFinite(nowMs) ? nowMs : Date.now();
136
+ return now - activityMs >= d * 24 * 60 * 60 * 1000;
137
+ }
138
+
111
139
  function friendlyBatchReason(item) {
112
140
  switch (item && item.reason) {
113
141
  case 'TASK_NOT_FOUND': return 'no longer exists';
@@ -128,6 +156,7 @@
128
156
  }
129
157
 
130
158
  const api = {
159
+ msOf, taskActivityMs, STALE_CHOICES, isStale,
131
160
  priorityRank, touchesConflict, rankTagHtml, deHashRefs,
132
161
  creditsPerMinute, buildPromptForTask, friendlyBatchReason,
133
162
  };
@@ -182,3 +182,11 @@ table.watch tr:hover td { background: var(--surface-2); }
182
182
  font-size: 13px; font-weight: 500;
183
183
  color: var(--accent-text);
184
184
  }
185
+
186
+ /* Invite by GitHub username (task 1003044) — the Access requests section's own
187
+ form: the way in on an invite-only project, a shortcut on an apply one. */
188
+ .watch-invite { margin: 0 0 14px; padding: 12px 14px; border: 1px solid var(--line, #e5e0d6); border-radius: 10px; }
189
+ .watch-invite__label { display: block; font-weight: 600; margin-bottom: 6px; }
190
+ .watch-invite__row { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
191
+ .watch-invite__row input[type="text"] { flex: 1 1 200px; min-width: 0; }
192
+ .watch-invite__why { margin: 8px 0 0; font-size: 13px; opacity: .8; }
@@ -88,7 +88,11 @@
88
88
  const res = await api.request('POST', `${API}${path}`, { body: body || {} });
89
89
  if (res.status === 401) { window.location.assign(`${API}/auth/web/start`); throw { kind: '401' }; }
90
90
  const data = res.data || {};
91
- if (!res.ok) throw new Error(data.message || data.error || `${path} → ${res.status}`);
91
+ if (!res.ok) {
92
+ // the envelope (ADR 0116) nests the sentence under `error`; older handlers answer flat
93
+ const env = data.error && typeof data.error === 'object' ? data.error : null;
94
+ throw new Error((env && env.message) || data.message || (typeof data.error === 'string' ? data.error : '') || `${path} → ${res.status}`);
95
+ }
92
96
  return data;
93
97
  }
94
98
 
@@ -183,14 +187,62 @@
183
187
  // 1. GATE — access requests (pending)
184
188
  // =========================================================================
185
189
 
190
+ // Invite by GitHub username (task 1003044): admits someone BEFORE any request
191
+ // exists — the only way in on an invite-only project, where nobody can apply,
192
+ // and a shortcut on an apply one. POST /access-requests/invite writes the same
193
+ // 'invited' row the sign-in gate reads. The door the project runs comes from
194
+ // the pack the server injected (window.__BRANDING__.project.joinability).
195
+ function inviteFormHtml() {
196
+ const p = window.__BRANDING__ && window.__BRANDING__.project;
197
+ const mode = (p && p.joinability) || 'apply';
198
+ const why = mode === 'invite_only'
199
+ ? 'This project is invite-only — nobody can apply, so this is how people get in.'
200
+ : mode === 'open'
201
+ ? 'This project is open — anyone who signs in is admitted, so an invite is rarely needed.'
202
+ : 'Admit someone before they ask — once invited, their sign-in gets in.';
203
+ return (
204
+ '<form class="watch-invite" id="gate-invite" novalidate>' +
205
+ '<label class="watch-invite__label" for="gate-invite-login">Invite by GitHub username</label>' +
206
+ '<div class="watch-invite__row">' +
207
+ '<input type="text" id="gate-invite-login" name="github_login" autocomplete="off" placeholder="e.g. octocat" maxlength="40">' +
208
+ '<button type="submit" class="watch-btn">Invite</button>' +
209
+ '</div>' +
210
+ `<p class="watch-invite__why">${escapeHtml(why)}</p>` +
211
+ '</form>'
212
+ );
213
+ }
214
+
215
+ function wireInviteForm(target) {
216
+ const form = target.querySelector('#gate-invite');
217
+ if (!form) return;
218
+ form.addEventListener('submit', async (e) => {
219
+ e.preventDefault();
220
+ const input = form.querySelector('#gate-invite-login');
221
+ const login = ((input && input.value) || '').trim().replace(/^@/, '');
222
+ if (!login) { toast('A GitHub username is needed.'); return; }
223
+ const btn = form.querySelector('button[type="submit"]');
224
+ if (btn) btn.disabled = true;
225
+ try {
226
+ const r = await postJson('/access-requests/invite', { github_login: login });
227
+ toast(r.already_invited ? `@${login} is already invited.` : `@${login} invited — their sign-in gets in.`);
228
+ await loadGate();
229
+ } catch (err) {
230
+ if (err && err.kind === '401') return;
231
+ if (btn) btn.disabled = false;
232
+ toast(`Could not invite: ${err.message || err}`);
233
+ }
234
+ });
235
+ }
236
+
186
237
  function renderGate(data, target) {
187
238
  const requests = Array.isArray(data.requests) ? data.requests : [];
188
239
  setAttention('att-access', requests.length); // tile count = table content
189
240
  if (!requests.length) {
190
- target.innerHTML = emptyStateHtml('No pending access requests.');
241
+ target.innerHTML = inviteFormHtml() + emptyStateHtml('No pending access requests.');
242
+ wireInviteForm(target);
191
243
  return;
192
244
  }
193
- target.innerHTML = requests
245
+ target.innerHTML = inviteFormHtml() + requests
194
246
  .map((r) => {
195
247
  const who = r.display_name
196
248
  ? `${escapeHtml(r.display_name)} <span class="watch-mono">@${escapeHtml(r.github_login)}</span>`
@@ -221,6 +273,7 @@
221
273
  toast(status === 'invited' ? 'Builder invited.' : 'Request dismissed.');
222
274
  await loadGate();
223
275
  }, 'Could not resolve');
276
+ wireInviteForm(target);
224
277
  }
225
278
 
226
279
  function loadGate() {