@bongos/core 1.19.562 → 1.19.564

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 (30) hide show
  1. package/.bongos-core.json +61 -31
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +2 -0
  4. package/clients/bongos-client/index.cjs +2 -0
  5. package/clients/bongos-client/index.d.ts +3 -0
  6. package/clients/bongos-client/index.mjs +2 -0
  7. package/docs/adr/0251-a-suggestion-of-who-to-invite-is-a-recruiting-surface-and-owes-the-opt-out.md +79 -0
  8. package/docs/adr/0252-the-self-hosted-visibility-report-is-a-pull-and-auto-appear-buys-a-retraction-lever.md +109 -0
  9. package/docs/adr/README.md +2 -0
  10. package/docs/api/openapi.json +69 -3
  11. package/docs/api-reference.md +3 -2
  12. package/docs/copy-inventory.md +127 -127
  13. package/docs/copy-registry.json +162 -162
  14. package/docs/module-api-changelog.md +4 -0
  15. package/modules/dev-box/app/src/vendor/bongos-client.cjs +2 -0
  16. package/modules/lifecycle/db-versions.js +22 -0
  17. package/modules/lifecycle/db.js +2 -1
  18. package/modules/lifecycle/routes/version-route-authz.js +56 -0
  19. package/modules/lifecycle/routes/versions.js +22 -0
  20. package/modules/platform-identity/invite-suggestions.js +57 -0
  21. package/modules/platform-identity/routes/sso.js +8 -0
  22. package/modules/provisioning/routes/provisioning.js +56 -0
  23. package/modules/public-landing/public/assets/cosmos.css +16 -0
  24. package/modules/public-landing/public/projects.html +85 -3
  25. package/package-lock.json +2 -2
  26. package/package.json +1 -1
  27. package/src/module-api.js +1 -1
  28. package/tests/one_planning_version.mjs +161 -0
  29. package/tests/project_invite_suggestions.mjs +133 -0
  30. package/tests/project_invite_ui.mjs +171 -18
@@ -1573,5 +1573,9 @@ is load-bearing: the script throws rather than guess if it is missing, and
1573
1573
  landed since 1.19.560 with no explicit bump. run 34048169727. (task 1002620)
1574
1574
  1.19.562 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1575
1575
  landed since 1.19.561 with no explicit bump. run 34048961786. (task 1002620)
1576
+ 1.19.563 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1577
+ landed since 1.19.562 with no explicit bump. run 34049959536. (task 1002620)
1578
+ 1.19.564 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1579
+ landed since 1.19.563 with no explicit bump. run 34052578659. (task 1002620)
1576
1580
  ---------------------------------------------------------------------------
1577
1581
  ```
@@ -676,6 +676,8 @@ function createClient(opts = {}) {
676
676
  postProvisioningInstancesIdForceTeardown: (args) => request("POST", "/provisioning/instances/{id}/force-teardown", { hasBody: true }, args),
677
677
  // POST /provisioning/instances/{id}/github-app — rank: any-builder — POST /provisioning/instances/:id/github-app
678
678
  postProvisioningInstancesIdGithubApp: (args) => request("POST", "/provisioning/instances/{id}/github-app", { hasBody: true }, args),
679
+ // GET /provisioning/instances/{id}/invite-suggestions — rank: any-builder — GET /provisioning/instances/:id/invite-suggestions
680
+ getProvisioningInstancesIdInviteSuggestions: (args) => request("GET", "/provisioning/instances/{id}/invite-suggestions", { hasBody: false }, args),
679
681
  // POST /provisioning/instances/{id}/restart — rank: any-builder — POST /provisioning/instances/:id/restart
680
682
  postProvisioningInstancesIdRestart: (args) => request("POST", "/provisioning/instances/{id}/restart", { hasBody: true }, args),
681
683
  // GET /provisioning/instances/{id}/settings — rank: any-builder — GET /provisioning/instances/:id/settings
@@ -81,6 +81,27 @@ async function versionProgress() {
81
81
  // commit together, so a bad criterion leaves nothing half-created. Criteria are
82
82
  // idempotent on (version_id, criterion_id) so a re-run can't double-insert.
83
83
 
84
+ // versionsWithStatus — the versions currently in a given state, id + name only.
85
+ // Added for the one-planning-version rule (BV1.R04, task 1003592, goal 1000086,
86
+ // ADR 0250 D5): with R03 closing a building version's goal set, cutting a second
87
+ // planning version is the obvious way around it — file the goal on a fresh
88
+ // version instead. One planning version means one queue for future work and no
89
+ // parallel escape hatch.
90
+ //
91
+ // NOTE ON "per project": this instance's `versions` table has no project column
92
+ // (migration 003) — a Bongos database IS one project — so "for a project" and
93
+ // "in this instance" are the same question here. If a project column ever lands,
94
+ // this is the read that needs the extra predicate, and the route above it needs
95
+ // nothing.
96
+ async function versionsWithStatus(status, deps = {}) {
97
+ const activePool = (deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : pool;
98
+ const { rows } = await activePool.query(
99
+ `SELECT id, name FROM versions WHERE status = $1 ORDER BY id`,
100
+ [status]
101
+ );
102
+ return rows;
103
+ }
104
+
84
105
  async function createVersion({ id, name, status = 'planning', track = 'product', scopeDocPath = null, doneWhen = null, criteria = [] }) {
85
106
  // versions.track is NOT NULL with no DB default (migration 003) — omitting it
86
107
  // here made every create 500. An instance's first version is its product
@@ -115,4 +136,5 @@ module.exports = {
115
136
  getVersion,
116
137
  listVersions,
117
138
  versionProgress,
139
+ versionsWithStatus,
118
140
  };
@@ -63,7 +63,7 @@ const {
63
63
  singleRungPromotionError,
64
64
  xenosClaimAllowed,
65
65
  } = require('./db-rank-authz.js');
66
- const { createVersion, getVersion, listVersions, versionProgress } = require('./db-versions.js');
66
+ const { createVersion, getVersion, listVersions, versionProgress, versionsWithStatus } = require('./db-versions.js');
67
67
  const {
68
68
  acceptMembershipRequestAndAddMember,
69
69
  achieveGoalIfComplete,
@@ -213,6 +213,7 @@ module.exports = {
213
213
  listVersions,
214
214
  getVersion,
215
215
  versionProgress,
216
+ versionsWithStatus,
216
217
  createVersion,
217
218
  createGoal,
218
219
  getGoal,
@@ -0,0 +1,56 @@
1
+ 'use strict';
2
+
3
+ // modules/lifecycle/routes/version-route-authz.js — the PURE predicates behind
4
+ // the version routes. Sibling of goal-route-authz.js, same contract: no express,
5
+ // no DB, no pool — the route gathers the facts, this decides, the route renders
6
+ // the decision verbatim.
7
+ //
8
+ // Carved on the same argument that file was: a decision expressed as a pure
9
+ // function has its whole truth table exercised by a DB-free unit test, including
10
+ // the shapes a live-Postgres test would never bother to set up (an unrecognised
11
+ // status, a null, a value that merely coerces to true). Inlining the same rule in
12
+ // the handler leaves it testable only by string-matching the source, which passes
13
+ // just as happily on a wrong branch or dead code.
14
+
15
+ // authorizeVersionCreate — may a version be created in this state?
16
+ // (BV1.R04, task 1003592, goal 1000086, ADR 0250 D5.)
17
+ //
18
+ // THE RULE: exactly one version sits in `planning` at a time. This is the second
19
+ // half of R03 (`authorizeGoalCreate`). Once a building version's goal set is
20
+ // closed, the first thing a refused caller reaches for is a NEW version to put
21
+ // the goal on — and the scope gate becomes decorative. One planning version means
22
+ // one queue for future work and no parallel escape hatch.
23
+ //
24
+ // Guards the PLANNING slot only: creating a `shipped` or `frozen` version is
25
+ // archival bookkeeping and no business of this rule. Refusing a second BUILDING
26
+ // version is D5's other half, task 1003606 (R19).
27
+ //
28
+ // `planning` is the versions already in that state (id + name), which the route
29
+ // reads. Passing the rows rather than a boolean is what lets the refusal NAME the
30
+ // version already holding the queue: "one version plans at a time" without saying
31
+ // which one sends the caller off to look it up, and a caller who looks it up is
32
+ // one step from cutting a version anyway.
33
+ //
34
+ // ON "per project": this instance's `versions` table has no project column
35
+ // (migration 003) — a Bongos database IS one project — so "for a project" and
36
+ // "in this instance" are the same question here. If a project column ever lands,
37
+ // the READ the route performs grows a predicate; this function does not change.
38
+ //
39
+ // Returns { ok: true } or { ok: false, status, body } — the exact HTTP status and
40
+ // JSON the route sends.
41
+ function authorizeVersionCreate({ status, planning = [] }) {
42
+ if (status !== 'planning') return { ok: true };
43
+ const existing = Array.isArray(planning) ? planning.filter(Boolean) : [];
44
+ if (existing.length === 0) return { ok: true };
45
+ return {
46
+ ok: false,
47
+ status: 409,
48
+ body: {
49
+ error: 'planning_version_exists',
50
+ message: `Version '${existing[0].id}' is already in planning — scope this work onto it, or ship it first. One version plans at a time.`,
51
+ planning: existing,
52
+ },
53
+ };
54
+ }
55
+
56
+ module.exports = { authorizeVersionCreate };
@@ -23,6 +23,7 @@ const auth = api;
23
23
  const db = require('../db');
24
24
  const versionClose = require('../version-close');
25
25
  const doneWhen = require('../done-when');
26
+ const { authorizeVersionCreate } = require('./version-route-authz.js');
26
27
  const { asyncHandler, validateOrRespond } = api;
27
28
 
28
29
  // versions.status CHECK (migration 003). A new version starts 'planning' or
@@ -114,6 +115,27 @@ module.exports = function buildVersionsRouter() {
114
115
  return res.fail('version_exists', { status: 409, message: `Version '${id}' already exists.` });
115
116
  }
116
117
 
118
+ // BV1.R04 (task 1003592, goal 1000086, ADR 0250 D5): exactly one version may
119
+ // sit in `planning`. The decision is the pure authorizeVersionCreate, the
120
+ // sibling of R03's authorizeGoalCreate — the route gathers the facts and
121
+ // renders the verdict, it does not carry the rule.
122
+ //
123
+ // RACE, ACCEPTED AND UNMITIGATED: this is check-then-insert, and no database
124
+ // constraint backs it — two concurrent POST /versions with status=planning
125
+ // could both pass. Unlike the id collision above, which Postgres catches on
126
+ // the primary key, nothing catches this one. The real fix is a partial unique
127
+ // index (`ON versions (status) WHERE status = 'planning'`), which is a
128
+ // migration this task did not reserve a number for; task 1003606 (R19) adds
129
+ // the second-BUILDING-version rule of the same shape and is the natural place
130
+ // to add the index for both. Until then the exposure is bounded by the route
131
+ // being Archon-gated and used a few times a year — small, not zero.
132
+ const versionDecision = authorizeVersionCreate({
133
+ status,
134
+ planning: status === 'planning' ? await db.versionsWithStatus('planning') : [],
135
+ });
136
+ if (!versionDecision.ok) {
137
+ return res.status(versionDecision.status).json(versionDecision.body);
138
+ }
117
139
  // Normalize criteria: each { criterion_id, criterion_md } (+ optional sort_order).
118
140
  const rawCriteria = Array.isArray(body.criteria) ? body.criteria : [];
119
141
  const criteria = [];
@@ -0,0 +1,57 @@
1
+ // modules/platform-identity/invite-suggestions.js — WHO is worth inviting to a
2
+ // project (BV1.R28, task 1002347, ADR 0251): the names behind the creation
3
+ // wizard's `invites.seeking`.
4
+ //
5
+ // The wizard's rule engine (modules/provisioning/recommendations.js) states WHAT
6
+ // to look for and cannot run the lookup — it is a pure file, and the directory
7
+ // lives in a module `provisioning` may not import (ADR 0239 §6, ADR 0083). This
8
+ // is the lookup, reached through the `platform-identity.hub` port.
9
+ //
10
+ // Its own file rather than another function in platform-identity.js: that file
11
+ // sits at the size ceiling, and this is a cohesive unit with one caller.
12
+
13
+ const { listScoutingRollup } = require('./platform-identity');
14
+
15
+ // Never suggest more than this many people however large a seek asks for — a
16
+ // suggestion is a short list, and the ceiling is what keeps the route from being
17
+ // turned into a directory dump by a caller that controls the limit.
18
+ const MAX_SUGGESTIONS = 25;
19
+
20
+ // listInviteSuggestions({ limit, exclude, pool }) → [{ github_login, display_name,
21
+ // avatar_url, disciplines, project_count, total_tasks_shipped }]
22
+ //
23
+ // IT READS listScoutingRollup AND NOT THE LEADERBOARD, WHICH IS THE WHOLE POINT.
24
+ // A suggestion of who to invite IS the recruiting surface, so it owes the
25
+ // recruiting opt-out — the axis `scoutingListableSql` carries and the economy
26
+ // leaderboard deliberately does not (community spec D3). Sourcing this from the
27
+ // public leaderboard would be cheaper, ungated, and would hand an owner the name
28
+ // of every builder who asked not to be recruited.
29
+ //
30
+ // `github_id` DOES NOT CROSS THE PORT. Callers get the login (what the invite
31
+ // route actually takes), the display name and the avatar — the same whitelist the
32
+ // invite widget already renders. The id identifies someone who has consented to
33
+ // nothing here, and the surface has a test pinning that it never appears.
34
+ //
35
+ // `exclude` is matched INSIDE, against the ids this function can see, so a caller
36
+ // can drop the owner from their own suggestion list without ever holding one.
37
+ async function listInviteSuggestions({ limit = 5, exclude = [], pool } = {}) {
38
+ const want = Math.max(0, Math.min(MAX_SUGGESTIONS, Number(limit) || 0));
39
+ if (want === 0) return [];
40
+ const skip = new Set((Array.isArray(exclude) ? exclude : [exclude]).map((v) => String(v ?? '')));
41
+ // Over-read by the exclusion count so filtering cannot return short of `want`
42
+ // when the owner is themselves near the top of their own directory.
43
+ const rows = await listScoutingRollup({ limit: want + skip.size, pool });
44
+ return rows
45
+ .filter((r) => !skip.has(String(r.github_id)) && r.github_login)
46
+ .slice(0, want)
47
+ .map((r) => ({
48
+ github_login: r.github_login,
49
+ display_name: r.display_name,
50
+ avatar_url: r.avatar_url,
51
+ disciplines: Array.isArray(r.disciplines) ? r.disciplines : [],
52
+ project_count: r.project_count,
53
+ total_tasks_shipped: r.total_tasks_shipped,
54
+ }));
55
+ }
56
+
57
+ module.exports = { listInviteSuggestions, MAX_SUGGESTIONS };
@@ -20,6 +20,7 @@ const pi = require('../platform-identity');
20
20
  const projectCatalog = require('../project-catalog');
21
21
  const scoutingAuthz = require('../scouting-authz');
22
22
  const canonicalProfile = require('../canonical-profile');
23
+ const inviteSuggestions = require('../invite-suggestions');
23
24
  const hubKeys = require('../hub-keys');
24
25
  const hubDevice = require('../hub-device');
25
26
  // task 1003208: structured logging (pino via the doorway) — was console.*.
@@ -122,6 +123,13 @@ function registerSeams() {
122
123
  // The ownership fact lives in this module, so the answer does too; core
123
124
  // asks through the port and treats an absent one as a denial.
124
125
  isProjectOwner: (githubId) => scoutingAuthz.hasOwnerMembership(githubId, { pool }),
126
+ // task 1002347 (BV1.R28, ADR 0251): the names behind `invites.seeking` —
127
+ // the creation wizard's "who is worth inviting" list. Consent-filtered by
128
+ // construction (it reads the recruiting directory, never the leaderboard)
129
+ // and projected without `github_id`. Provisioning asks through the port
130
+ // because the directory lives here; an absent provider means an instance
131
+ // with no hub to ask, which is "no suggestions", never an error.
132
+ listInviteSuggestions: (opts) => inviteSuggestions.listInviteSuggestions({ ...opts, pool }),
125
133
  // task 1002804 (P08, ADR 0214 D3): the account's PUBLIC handle — the
126
134
  // canonical profile address — so the numeric profile route can name a
127
135
  // builder by the rename-proof key instead of a mutable github_login.
@@ -17,6 +17,7 @@
17
17
  // (also SELF-HEALS its catalog projection,
18
18
  // task 1003581 — see the handler)
19
19
  // POST /provisioning/instances/:id/teardown — request teardown of one's own
20
+ // GET /provisioning/instances/:id/invite-suggestions — who is worth inviting (R28, ADR 0251)
20
21
  // Archon-scoped (requireRank('archon') — the fleet, the /boxes precedent):
21
22
  // GET /provisioning/fleet — the full instance roster
22
23
  // GET /provisioning/fleet/cost-ledger — per-instance + total cost estimate
@@ -771,6 +772,61 @@ module.exports = function provisioningRoutes() {
771
772
  }
772
773
  });
773
774
 
775
+ // ── Who is worth inviting (BV1.R28, task 1002347, goal 1000046 C6) ────────
776
+ // GET /provisioning/instances/:id/invite-suggestions — turn the engine's
777
+ // `invites.seeking` into NAMES. ADR 0239 §6 left `invites.suggested` empty on
778
+ // purpose: ranking builders is a directory read the pure engine may not run.
779
+ // This is the resolution, and ADR 0251 is why it looks like this.
780
+ //
781
+ // rank: any authenticated builder, own resource — `instanceAccessDenied`, the
782
+ // same gate and the same 404 as the manage read above. That is deliberate and
783
+ // not a new authorization idea: REACHING this route IS the ownership answer
784
+ // (ADR 0249 §4), so the surface never re-derives "is this theirs" and can never
785
+ // become a weaker second copy of a server rule (ADR 0016). It also 404s rather
786
+ // than 403s, so it leaks no more instance existence than the read beside it.
787
+ //
788
+ // `seeking: null` COSTS NOTHING AND ASKS NOBODY. A solo owner is suggested
789
+ // nobody on purpose (ADR 0239 §6), which is a different statement from an empty
790
+ // list nobody filled — so that answer returns its own reason and never touches
791
+ // the directory at all.
792
+ router.get('/provisioning/instances/:id/invite-suggestions', auth.requireBuilder, async (req, res) => {
793
+ const id = parseId(req, res);
794
+ if (!id) return;
795
+ try {
796
+ const inst = await provisioning.getInstanceById(pool, id);
797
+ if (instanceAccessDenied(inst, req.builder)) return res.fail('instance_not_found', 404);
798
+
799
+ const detail = provisioning.projectDetail(inst) || {};
800
+ const { invites } = recommendationsForDetails({ type: inst.type, ...detail });
801
+
802
+ // Nobody to look for — the engine's own answer, returned with its reason.
803
+ if (!invites.seeking) {
804
+ return res.json({ suggested: [], seeking: null, reason: invites.reason });
805
+ }
806
+
807
+ // resolveOptional, not resolve, and NOT declared in `consumes` — the
808
+ // catalog-bridge precedent: platform-identity is hub-only (`default:
809
+ // false`), so an instance running provisioning without it has no directory
810
+ // to ask and the honest answer is "no suggestions", never a 500.
811
+ const hub = api.resolveOptional('platform-identity.hub');
812
+ if (!hub || typeof hub.listInviteSuggestions !== 'function') {
813
+ return res.json({ suggested: [], seeking: invites.seeking, reason: invites.reason, unavailable: 'no_directory' });
814
+ }
815
+
816
+ // The owner is dropped from their own list. They are frequently the top
817
+ // builder on their own hub, and "invite yourself" is noise on the one
818
+ // screen whose whole job is naming other people.
819
+ const suggested = await hub.listInviteSuggestions({
820
+ limit: invites.seeking.limit,
821
+ exclude: [req.builder.github_id].filter(Boolean),
822
+ });
823
+ res.json({ suggested, seeking: invites.seeking, reason: invites.reason });
824
+ } catch (err) {
825
+ log.error('[provisioning] GET /provisioning/instances/:id/invite-suggestions', err);
826
+ res.fail('invite_suggestions_failed', { status: 500, message: 'internal error' });
827
+ }
828
+ });
829
+
774
830
  // ── Per-project settings (task 1003040, goal 1000058) ─────────────────────
775
831
  // The foundation the manage-page wave hangs off: effective settings are the
776
832
  // DEFAULTS (today's behavior — public surfaces, apply-to-join) overlaid with
@@ -421,6 +421,22 @@ h1{font-family:var(--font-display);font-weight:700;font-size:clamp(24px,3.4vw,32
421
421
  .inviteList>li::before{content:"";grid-column:1;grid-row:1;width:7px;height:7px;margin-top:7px;border-radius:50%;border:1px solid var(--accent);background:var(--bg);}
422
422
  .inviteList b{grid-column:2;font-family:var(--font-body);font-weight:500;font-size:15px;color:var(--ink);}
423
423
  .inviteList small{grid-column:2;font-family:var(--font-body);font-size:13px;font-weight:400;line-height:1.45;color:var(--ink-faint);}
424
+ /* who is worth inviting (task 1002347, BV1.R28) — the recommendation's own
425
+ sentence, in the .modWhy voice: the panel explaining itself, never a thing to
426
+ act on, so it never competes with the names under it. */
427
+ .inviteWhy{display:none;}
428
+ .inviteWhy.on{display:block;margin:12px 0 0;max-width:62ch;font-size:13.5px;font-weight:400;color:var(--ink-soft);line-height:1.5;}
429
+ /* the suggested names. The SAME hairline ledger as .inviteList — they are two
430
+ halves of one answer and stack directly on each other — with the action on the
431
+ end of the row instead of a dot on the front of it. */
432
+ .suggList{list-style:none;display:grid;margin:10px 0 0;padding:0;}
433
+ .suggList:empty{display:none;}
434
+ .suggList>li{display:flex;align-items:center;gap:12px;padding:10px 0 11px;border-top:1px solid var(--rule-soft);}
435
+ .suggList>li:first-child{border-top:0;}
436
+ .suggWho{display:grid;gap:2px;flex:1 1 auto;min-width:0;}
437
+ .suggWho b,.suggWho{font-family:var(--font-body);font-weight:500;font-size:15px;color:var(--ink);}
438
+ .suggWho small{font-family:var(--font-body);font-size:13px;font-weight:400;line-height:1.45;color:var(--ink-faint);}
439
+ .suggList .btn{flex:0 0 auto;}
424
440
  .attachNote code{font-family:var(--font-mono);}
425
441
  .projMsg{font-size:13.5px;font-weight:600;margin-top:10px;min-height:18px;color:var(--ink-soft);line-height:1.5;}
426
442
  .projMsg.ok{color:var(--ok);}
@@ -865,6 +865,8 @@ summary{min-height:24px;padding:3px 0;}
865
865
  <input type="text" id="mInviteWho" placeholder="GitHub username" autocomplete="off" autocapitalize="off" spellcheck="false" maxlength="200">
866
866
  <button type="button" class="btn small" data-invite-send="mInvite">Invite</button>
867
867
  </div>
868
+ <div class="inviteWhy" id="mInviteWhy"></div>
869
+ <ul class="suggList" id="mInviteSugg"></ul>
868
870
  <ul class="inviteList" id="mInviteList"></ul>
869
871
  <div class="projMsg" id="mInviteMsg" role="status" aria-live="polite"></div>
870
872
  </div>
@@ -1240,6 +1242,8 @@ summary{min-height:24px;padding:3px 0;}
1240
1242
  <button type="button" class="btn small" data-invite-send="wizInvite">Invite</button>
1241
1243
  <button type="button" class="btn small ghost" data-invite-skip="wizInvite">Skip</button>
1242
1244
  </div>
1245
+ <div class="inviteWhy" id="wizInviteWhy"></div>
1246
+ <ul class="suggList" id="wizInviteSugg"></ul>
1243
1247
  <ul class="inviteList" id="wizInviteList"></ul>
1244
1248
  <div class="projMsg" id="wizInviteMsg" role="status" aria-live="polite"></div>
1245
1249
  </div>
@@ -4148,6 +4152,7 @@ summary{min-height:24px;padding:3px 0;}
4148
4152
  sets only AFTER this runs. */
4149
4153
  var created = createdRecord();
4150
4154
  setInviteSlug('wizInvite', (me && created && created.id && created.slug) ? created.slug : '');
4155
+ loadInviteSuggestions('wizInvite', (me && created && created.slug) ? created.id : '');
4151
4156
  $('wizInviteLater').innerHTML = 'You can invite builders any time from ' + projectPageLink('the project’s own page') + '.';
4152
4157
  renderSteps();
4153
4158
  }
@@ -5336,6 +5341,7 @@ summary{min-height:24px;padding:3px 0;}
5336
5341
  who is not the owner, so reaching renderManage at all IS the ownership
5337
5342
  answer the widget does not ask for itself. */
5338
5343
  setInviteSlug('mInvite', inst.slug || '');
5344
+ loadInviteSuggestions('mInvite', inst.slug ? inst.id : '');
5339
5345
  /* the page names its project — the heading holds #mName; and in the tab */
5340
5346
  document.title = (inst.slug ? inst.slug + ' — ' : '') + BASE_TITLE;
5341
5347
  /* the card's own story, on the project's own page (task 1003232): the
@@ -6777,12 +6783,13 @@ summary{min-height:24px;padding:3px 0;}
6777
6783
  Proof: ../../../tests/project_invite_ui.mjs ══ */
6778
6784
 
6779
6785
  var INVITE_MOUNTS = {
6780
- wizInvite: { who: 'wizInviteWho', list: 'wizInviteList', msg: 'wizInviteMsg', after: 'doneStepsHead' },
6781
- mInvite: { who: 'mInviteWho', list: 'mInviteList', msg: 'mInviteMsg', after: null }
6786
+ wizInvite: { who: 'wizInviteWho', list: 'wizInviteList', msg: 'wizInviteMsg', after: 'doneStepsHead', why: 'wizInviteWhy', sugg: 'wizInviteSugg' },
6787
+ mInvite: { who: 'mInviteWho', list: 'mInviteList', msg: 'mInviteMsg', after: null, why: 'mInviteWhy', sugg: 'mInviteSugg' }
6782
6788
  };
6783
6789
  var inviteSlug = {}; /* mount id → the slug it invites to; '' means no step at all */
6784
6790
  var inviteRows = {}; /* mount id → the invites filed on THIS project this session */
6785
6791
  var inviteBusy = {}; /* a send is in flight — the mount is inert until it answers */
6792
+ var inviteSugg = {}; /* mount id → { suggested, reason } — who is worth inviting (R28) */
6786
6793
 
6787
6794
  /* setInviteSlug(id, slug) — the only door in. A falsy slug closes the mount, which
6788
6795
  is how "signed out", "not the owner" and "no project yet" all reach this widget.
@@ -6795,6 +6802,7 @@ summary{min-height:24px;padding:3px 0;}
6795
6802
  if (inviteSlug[id] !== next) {
6796
6803
  inviteSlug[id] = next;
6797
6804
  inviteRows[id] = [];
6805
+ inviteSugg[id] = null;
6798
6806
  inviteSay(id, '', '');
6799
6807
  }
6800
6808
  var box = $(id);
@@ -6802,6 +6810,68 @@ summary{min-height:24px;padding:3px 0;}
6802
6810
  renderInviteRows(id);
6803
6811
  }
6804
6812
 
6813
+ /* setInviteSuggestions(id, payload) — the SECOND door in, and deliberately a
6814
+ separate one. The widget still answers neither "is anyone signed in" nor "is
6815
+ this their project" (ADR 0249): the caller has already been told both by the
6816
+ route it fetched, and hands over the answer or nothing.
6817
+
6818
+ `payload.suggested` empty with a reason is a real answer, not a blank state —
6819
+ a solo owner is suggested nobody ON PURPOSE (ADR 0239 §6), and the reason is
6820
+ the sentence that says so in their own words. */
6821
+ function setInviteSuggestions(id, payload) {
6822
+ if (!INVITE_MOUNTS[id]) return;
6823
+ inviteSugg[id] = payload || null;
6824
+ renderInviteSugg(id);
6825
+ }
6826
+
6827
+ /* The suggestion rows, each carrying its own Invite button. A name already
6828
+ invited this session drops out — the pending line below IS its receipt, and
6829
+ offering the same person twice invites a refusal the owner cannot act on. */
6830
+ function renderInviteSugg(id) {
6831
+ var m = INVITE_MOUNTS[id];
6832
+ var list = m && $(m.sugg);
6833
+ var why = m && $(m.why);
6834
+ if (!list || !why) return;
6835
+ var payload = inviteSugg[id];
6836
+ var reason = (payload && payload.reason) || '';
6837
+ why.textContent = reason;
6838
+ why.classList.toggle('on', !!reason);
6839
+ var already = {};
6840
+ (inviteRows[id] || []).forEach(function (r) { already[String(r.login).toLowerCase()] = true; });
6841
+ var rows = ((payload && payload.suggested) || []).filter(function (b) {
6842
+ return b && b.github_login && !already[String(b.github_login).toLowerCase()];
6843
+ });
6844
+ list.innerHTML = rows.map(function (b) {
6845
+ /* the login is the address AND the label: display_name is a nicety the row
6846
+ may not carry, and github_id is never shown (it names someone who has
6847
+ accepted nothing — the same pin the pending lines carry). */
6848
+ var named = b.display_name && b.display_name !== b.github_login
6849
+ ? esc(b.display_name) + ' <small>@' + esc(b.github_login) + '</small>'
6850
+ : '<b>@' + esc(b.github_login) + '</b>';
6851
+ var shipped = Number(b.total_tasks_shipped || 0);
6852
+ var projects = Number(b.project_count || 0);
6853
+ var record = shipped
6854
+ ? shipped + (shipped === 1 ? ' task shipped' : ' tasks shipped') + (projects ? ' across ' + projects + (projects === 1 ? ' project' : ' projects') : '')
6855
+ : '';
6856
+ return '<li><span class="suggWho">' + named + (record ? '<small>' + esc(record) + '</small>' : '') + '</span>'
6857
+ + '<button type="button" class="btn small ghost" data-invite-pick="' + esc(b.github_login) + '">Invite</button></li>';
6858
+ }).join('');
6859
+ }
6860
+
6861
+ /* loadInviteSuggestions(id, instanceId) — ask the own-scoped route who is worth
6862
+ inviting. Every failure is SILENT by construction: a suggestion is an extra,
6863
+ and a project that was just created successfully must never look broken
6864
+ because an optional directory read did not answer. */
6865
+ function loadInviteSuggestions(id, instanceId) {
6866
+ if (!instanceId) { setInviteSuggestions(id, null); return; }
6867
+ fetch(API + '/provisioning/instances/' + encodeURIComponent(instanceId) + '/invite-suggestions', {
6868
+ credentials: 'same-origin'
6869
+ }).then(function (res) {
6870
+ if (!res.ok) { setInviteSuggestions(id, null); return; }
6871
+ return res.json().then(function (data) { setInviteSuggestions(id, data); });
6872
+ }).catch(function () { setInviteSuggestions(id, null); });
6873
+ }
6874
+
6805
6875
  /* One line per invite this session filed: the login the inviter typed, and the
6806
6876
  WORD THE ROW ITSELF CARRIES — so a membership_kind other than 'pending' shows
6807
6877
  up here rather than being papered over as one. */
@@ -6813,6 +6883,9 @@ summary{min-height:24px;padding:3px 0;}
6813
6883
  var said = r.kind === 'pending' ? 'invited — waiting for them to accept.' : r.kind;
6814
6884
  return '<li><b>' + esc(r.login) + '</b><small>' + esc(said) + '</small></li>';
6815
6885
  }).join('');
6886
+ /* the two lists are one answer: a name that just became a pending row is no
6887
+ longer a suggestion, and they are always redrawn together. */
6888
+ renderInviteSugg(id);
6816
6889
  }
6817
6890
 
6818
6891
  function inviteSay(id, text, cls, isHtml) {
@@ -6896,8 +6969,17 @@ summary{min-height:24px;padding:3px 0;}
6896
6969
  var box = $(id);
6897
6970
  if (box) {
6898
6971
  box.addEventListener('click', function (ev) {
6899
- var t = ev.target.closest && ev.target.closest('[data-invite-send],[data-invite-skip]');
6972
+ var t = ev.target.closest && ev.target.closest('[data-invite-send],[data-invite-skip],[data-invite-pick]');
6900
6973
  if (!t) return;
6974
+ /* a picked suggestion fills the field and takes the SAME path as a typed
6975
+ name — one write path, so the refusal vocabulary, the busy guard and the
6976
+ pending receipt can never drift into a second copy. */
6977
+ if (t.hasAttribute('data-invite-pick')) {
6978
+ var field = $(INVITE_MOUNTS[id].who);
6979
+ if (field) field.value = t.getAttribute('data-invite-pick') || '';
6980
+ doInvite(id);
6981
+ return;
6982
+ }
6901
6983
  if (t.hasAttribute('data-invite-skip')) skipInvite(id); else doInvite(id);
6902
6984
  });
6903
6985
  }
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.562",
3
+ "version": "1.19.564",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.562",
9
+ "version": "1.19.564",
10
10
  "license": "AGPL-3.0-or-later",
11
11
  "dependencies": {
12
12
  "express": "^4.21.2",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.562",
3
+ "version": "1.19.564",
4
4
  "description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "main": "src/platform-server.js",
package/src/module-api.js CHANGED
@@ -49,7 +49,7 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
49
49
  // there. scripts/gds/bump-version.js still rewrites the literal below; it appends
50
50
  // the entry to that file. Look for a version's history there, not here.
51
51
  // ---------------------------------------------------------------------------
52
- const CORE_VERSION = '1.19.562'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
52
+ const CORE_VERSION = '1.19.564'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
53
53
 
54
54
  // A namespaced logger so a module's log lines are attributable + consistent.
55
55
  // Usage: const log = api.logger('dev-box'); log.info('mounted');