@bongos/core 1.21.86 → 1.21.87

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.
package/.bongos-core.json CHANGED
@@ -2,22 +2,22 @@
2
2
  "artifact": "bongos-core",
3
3
  "manifest_schema": 1,
4
4
  "generator": "scripts/gds/package-core.js",
5
- "core_version": "1.21.86",
6
- "core_contract": "1.21.86",
7
- "source_commit": "7183f6e501a4de2ea256ffa4c2d19ccc62d7fdc1",
5
+ "core_version": "1.21.87",
6
+ "core_contract": "1.21.87",
7
+ "source_commit": "3a000da66e4b7ed910b59eb0e1843e81a7e3f7fd",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-10-09T04:48:08.126Z",
9
+ "built_at": "2026-10-09T05:06:35.756Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
12
  "docs_redacted": 590,
13
13
  "agent_docs_stubbed": 28,
14
- "functional_verbatim": 2998,
14
+ "functional_verbatim": 3000,
15
15
  "rules": 3,
16
16
  "gate_literals": 3,
17
17
  "gate": "passed"
18
18
  },
19
- "file_count": 3617,
20
- "tree_sha256": "391c304f1f86515a2c812e6764e83ba98b5fe6a67757909baf15d8e88009694b",
19
+ "file_count": 3619,
20
+ "tree_sha256": "94cb33a270055b9c3e008a1a26ad2e66038572aae76148fccb2f9b3bf2bbecf9",
21
21
  "files": [
22
22
  {
23
23
  "path": ".claude/skills/ask-for-help/SKILL.md",
@@ -2432,7 +2432,7 @@
2432
2432
  {
2433
2433
  "path": "docs/design/project-startup-direction.md",
2434
2434
  "mode": "0000644",
2435
- "sha256": "8b95dbb81af5cad9a85d3ffe9532d60391329cc957d5c79327e187bbfb3551de"
2435
+ "sha256": "8cac194bfa4e7e0cfa6785ff72fd141980eb2abed6497ce56e42585c2f4a918c"
2436
2436
  },
2437
2437
  {
2438
2438
  "path": "docs/design/projects-hub-direction-v2.md",
@@ -2862,7 +2862,7 @@
2862
2862
  {
2863
2863
  "path": "docs/module-api-changelog.md",
2864
2864
  "mode": "0000644",
2865
- "sha256": "007f5683bb299be37f78f220958ae14bb8a8069fc59b81cc6fb94a12c5b3712f"
2865
+ "sha256": "39126044930a9015689ba736100232d99d1d13d43d723ec95a15dbe505c4390d"
2866
2866
  },
2867
2867
  {
2868
2868
  "path": "docs/modules-contract.md",
@@ -10097,12 +10097,12 @@
10097
10097
  {
10098
10098
  "path": "package-lock.json",
10099
10099
  "mode": "0000644",
10100
- "sha256": "9d3ecc5f558e51dc8e4e66a6f3f0a6e9773e517ce9571ae4ec130b33128536ff"
10100
+ "sha256": "6953a80922eac656bc533d17e921f49228e939cacb64da8e56d1a90fa5f358db"
10101
10101
  },
10102
10102
  {
10103
10103
  "path": "package.json",
10104
10104
  "mode": "0000644",
10105
- "sha256": "ce90ad007650b7bcf0705bf0743acf66507496e0a043d7d6c2d9b84795b94f9b"
10105
+ "sha256": "be88b7b575574dc07cbf071c639c419a4365cd89e7ed0b7bba058de98982b443"
10106
10106
  },
10107
10107
  {
10108
10108
  "path": "public-docs/index.html",
@@ -10122,7 +10122,7 @@
10122
10122
  {
10123
10123
  "path": "release-notes.json",
10124
10124
  "mode": "0000644",
10125
- "sha256": "99cc3e08c1384339c868c499061c081eade1bcb0a996c47d3e644066d22ca536"
10125
+ "sha256": "ba7ae503bc8f34ef8244b2fb989462283e4128b6ea03ac066d050acfb35edbc5"
10126
10126
  },
10127
10127
  {
10128
10128
  "path": "scripts/cron/style_guide_audit.sh",
@@ -11734,10 +11734,15 @@
11734
11734
  "mode": "0000644",
11735
11735
  "sha256": "9f6ce80e5325f4cfe68cfa6cb82bec52d594af7e461b6734724819b23cbb0d37"
11736
11736
  },
11737
+ {
11738
+ "path": "scripts/gds/start-goals.js",
11739
+ "mode": "0000644",
11740
+ "sha256": "8c4e1f1c7871c82b0fefd5402c93888831543dcd776a1e761e7b1238b3afafaa"
11741
+ },
11737
11742
  {
11738
11743
  "path": "scripts/gds/start.js",
11739
11744
  "mode": "0000644",
11740
- "sha256": "4bc438bb7859c7a405088b2e386b33fb550532a71dbe66866d711e5b1d4b3e69"
11745
+ "sha256": "181f07b8d62f03ae001d08dd5e5b15a65b33f512fb548e55401b97f7f484f31f"
11741
11746
  },
11742
11747
  {
11743
11748
  "path": "scripts/gds/status.js",
@@ -12372,7 +12377,7 @@
12372
12377
  {
12373
12378
  "path": "src/module-api.js",
12374
12379
  "mode": "0000644",
12375
- "sha256": "2fd52ee0afe6fc9ade992a558ebb0fc3ad5904f876f7c5093dd60ea5d201b448"
12380
+ "sha256": "3ba47cbdc5c1c0dc86af2cf1302a6ffc3c9966a7646f88bd2a004f5dd67b0ded"
12376
12381
  },
12377
12382
  {
12378
12383
  "path": "src/module-loader/catalog.js",
@@ -17529,6 +17534,11 @@
17529
17534
  "mode": "0000644",
17530
17535
  "sha256": "39e62a5fc50113e719cdaacf15e6fd054f38f8703fc64137eddee7901b374f16"
17531
17536
  },
17537
+ {
17538
+ "path": "tests/start_goal_first.mjs",
17539
+ "mode": "0000644",
17540
+ "sha256": "a3addba1547bf0a9ce95f5d6c2ea676f63495669154e2f4c161cb8cc543b30bf"
17541
+ },
17532
17542
  {
17533
17543
  "path": "tests/start_inbox_count.mjs",
17534
17544
  "mode": "0000644",
@@ -10,7 +10,64 @@ This document is what every build task in goal 1000121 cites. Name a screen by i
10
10
  - **what the new design ADDS**;
11
11
  - the decisions that govern both.
12
12
 
13
- ## The blended flow — supersedes the hub-side screens (owner, 2026-10-01)
13
+ ## One-page flow — supersedes the blended six screens (owner, 2026-10-08)
14
+
15
+ > *Summary wins, do it in the night-room look, if it's day time let's make a day view, and likewise, same for the artist page.* Then: *approved.* — the owner, 2026-10-08.
16
+
17
+ The owner found the six-screen flow still overcomplicated, and the GitHub steps after Create still failing. The mockup in [`mocks/day-night-rooms/`](mocks/day-night-rooms/README.md) is now the design of record for **every hub-side creation screen and for the artist studio's room**. Where it disagrees with the blended flow below, the mockup wins. Genesis (part 2) and the blend rule are unchanged.
18
+
19
+ ### The owner's decisions
20
+
21
+ 1. **One page, three parts.** Sign-in stays the gate in front. Then one page:
22
+ - **01 Your project:** name, kind, the optional sentence, and the four quick checks (country, anyone under 18, where builders will be, who's behind it). The screening stop still fires before anything is created.
23
+ - **02 How it's run:** a summary to skim. Each row (code, address, team, extras, rewards, hosting, we run, look) has a **Change** link that opens the real control in place.
24
+ - **03 Connect GitHub:** opens once the project exists, below **Create my project**.
25
+ 2. **The summary wins.** No row needs confirming and Create is not gated on the rows. This **replaces** the blended flow's "the review must not be skippable" rule. The terms stay a required checkbox above Create, and the terms version stamp still records the acceptance.
26
+ 3. **GitHub in one trip (option A).** One click: approve the project's app on GitHub, go straight on to installing it on the repo, and land back on the page. A checklist ticks itself from what the server has verified (approve the app · install it on your repo · builders can sign in · command-line sign-in is on). Coming back works from any tab or device. The founder chooses a personal or an organisation account. A failure says what stopped, and "Pick up where it stopped" resumes without making a second app.
27
+ 4. **Day room and night room, by the viewer's own clock.** Day from 07:00 to 18:59 local time, night otherwise. The day room is the same drawing in daylight: a sunny sky with clouds, a sun for the moon, lighter towers with unlit windows, a warm wall and floor, the lamp off and a patch of sun on the floor. Its palette is the mockup's `[data-sky="day"]` tokens. **The artist studio gets the same day room**, and its greeting follows the time of day.
28
+ 5. **The demo is a small link** under the heading, not its own screen.
29
+
30
+ ### Nothing the live wizard does is lost
31
+
32
+ This table replaces the blended flow's table below for every hub-side capability. The mockup draws the simple path; each row's capability is a reveal or sits behind a summary row's **Change**.
33
+
34
+ | Today | In the one-page flow |
35
+ |---|---|
36
+ | Q1 new or existing, and what already exists | Part 02's Code row: "A repo I already have" reveals what already exists and the adopt path. |
37
+ | Adopt: GitHub repo picker, manual fallback, conflict pre-flight, backlog import | Behind the Code row's Change, unchanged. |
38
+ | Q2 geography | Part 01's "Where are you?", with state or province revealed beside the country for the countries that need it, and "Where will builders be?". |
39
+ | Q3 under-18s and the age tiers | Part 01; "Yes" reveals the tiers and the guardian rules in place. |
40
+ | Q4 entity, new intent or existing legal type | Part 01's "Who's behind it?"; a company or nonprofit reveals the full entity list. |
41
+ | The screening stop | Unchanged: an answer the platform cannot serve stops the page before Create, with the reason in place. |
42
+ | Q5 one or many, count, growth guess | Part 02's Team row; Change opens the count, the growth guess and how people join. |
43
+ | Q6 rewards | Part 02's Rewards row (pick several). |
44
+ | Q7 framing A–D and the hybrid suite | Part 02's **We run** row ("Everything, for you" by default); "Some of it" reveals the service checklist. |
45
+ | Auto-fill from my account | Kept, as an offer at the top of part 01 when the account has a last project. |
46
+ | Resume a saved draft | Kept, as an offer at the top of the page (there is no fork screen any more). |
47
+ | Type and About | Part 01's Kind and "In a sentence". |
48
+ | Modules picker and search | Part 02's Extras row; Change opens the searchable picker. |
49
+ | Address: handle, own domain, no address | Part 02's Address row (PS19: shown on every path, the demo included). |
50
+ | App: Render key check, workspace, runtime, commands, plan, database | Part 02's Hosting row; Render opens today's settings in place. |
51
+ | Look, logo, night tint | Part 02's Look row; Change opens the look picker, the logo and the night tint. |
52
+ | Terms acknowledgement | The required checkbox above Create; the terms version stamp still records it. |
53
+ | Review | Part 02 itself, plus the one-line recap beside Create. Nothing is gated on confirming rows. |
54
+ | Done: standup strip, the owner-only steps, invite | Part 03 on the same page: the GitHub checklist replaces the paged owner-only steps, then "is alive" with the invite box (once the hall is live, PS15) and the genesis door. |
55
+ | Demo: fit check, settings, teammates, 30-day notice | The "Try a small demo instead" link opens the demo's people, hours, idea and teammates in place at the top of part 01, marks the page "demo", and shows the fit sentence; the rest of the page is unchanged. |
56
+
57
+ ### Who builds it
58
+
59
+ | Task | Builds |
60
+ |---|---|
61
+ | task 1004647 | This record and the mockup. |
62
+ | task 1004648 | The one page, the day room on it, and removing the old panels. |
63
+ | task 1004649 | GitHub in one trip, with the self-ticking checklist. |
64
+ | task 1004650 | The hand-back from any tab or device, and checking while the page is hidden. |
65
+ | task 1004651 | A personal or organisation account for the app. |
66
+ | task 1004652 | Resuming a stopped step, and a fixing button for every failure. |
67
+ | task 1004653 | The end-to-end walk, day and night, desktop and phone. |
68
+ | task 1004654 | The artist studio's day room and time-of-day greeting. |
69
+
70
+ ## The blended flow — superseded by the one-page flow above (owner, 2026-10-01)
14
71
 
15
72
  > *Go for this as the design of record.* — the owner, approving the blended mockup (task 1004515).
16
73
 
@@ -3004,5 +3004,7 @@ is load-bearing: the script throws rather than guess if it is missing, and
3004
3004
  landed since 1.21.84 with no explicit bump. run 37884200093. (task 1002620)
3005
3005
  1.21.86 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
3006
3006
  landed since 1.21.85 with no explicit bump. run 37885516955. (task 1002620)
3007
+ 1.21.87 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
3008
+ landed since 1.21.86 with no explicit bump. run 37886940909. (task 1002620)
3007
3009
  ---------------------------------------------------------------------------
3008
3010
  ```
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.21.86",
3
+ "version": "1.21.87",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.21.86",
9
+ "version": "1.21.87",
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.21.86",
3
+ "version": "1.21.87",
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",
@@ -9342,5 +9342,15 @@
9342
9342
  "id": "1004399",
9343
9343
  "text": "Published module packages are now kept in the project's own data folder instead of inside the installed core, so a core upgrade no longer deletes them; any already stored in the old place are moved over when the server starts."
9344
9344
  }
9345
+ ],
9346
+ "1.21.87": [
9347
+ {
9348
+ "id": "1004647",
9349
+ "text": "The new project creation design is now on record for every builder: one page in three parts instead of seven screens, a skimmable summary instead of confirming every row, GitHub in a single trip with a checklist that ticks its"
9350
+ },
9351
+ {
9352
+ "id": "1002852",
9353
+ "text": "The task list a builder sees at the start of a session now opens with the goals they have joined and how much work in each is ready, and groups the tasks under their goals, with the builder's own goals first and anything witho"
9354
+ }
9345
9355
  ]
9346
9356
  }
@@ -0,0 +1,102 @@
1
+ // scripts/gds/start-goals.js — /builder-start, goal-first (task 1002852).
2
+ //
3
+ // WHY. Work is organised in goals, but /builder-start opened on a flat list of
4
+ // tasks, so a builder saw "task 1002852" and never which goal it served or which of
5
+ // their own goals had work waiting. This leads with the goals the builder has
6
+ // JOINED, then shows the same claimable table grouped under each task's goal.
7
+ //
8
+ // The owner's two rules (2026-10-08, WA6 chain kickoff):
9
+ // - the goals sit ABOVE the claimable table and never replace it, so nothing
10
+ // claimable is hidden from a builder who has joined no goal;
11
+ // - a task with no goal is listed under "Other work". Nothing is backfilled.
12
+ //
13
+ // PURE: no I/O. start.js fetches GET /goals (best-effort: a failed read groups by
14
+ // goal id with a generic name and lists no joined goals) and hands both lists in.
15
+
16
+ 'use strict';
17
+
18
+ const OTHER_WORK = 'Other work';
19
+ const JOINED_SHOWN = 5;
20
+
21
+ // The open goals this builder is a member of, in the board's own order (version,
22
+ // sort order). `viewer_is_member` is the server's answer, never re-derived here.
23
+ function joinedGoals(goals) {
24
+ return (Array.isArray(goals) ? goals : [])
25
+ .filter((g) => g && g.viewer_is_member === true && g.status === 'open');
26
+ }
27
+
28
+ function goalName(id, byId) {
29
+ const g = byId.get(String(id));
30
+ return (g && g.title) || `Goal ${id}`;
31
+ }
32
+
33
+ // Group claimable rows by goal, keeping each row's place in the server's order
34
+ // (priority, then estimate) inside its group. Groups run: the builder's joined
35
+ // goals first (board order), then every other goal by its first row, then
36
+ // "Other work" last. Every row lands in exactly one group, so the grouped table
37
+ // lists precisely the rows the flat one did.
38
+ function groupByGoal(rows, goals) {
39
+ const list = Array.isArray(rows) ? rows : [];
40
+ const byId = new Map((Array.isArray(goals) ? goals : []).map((g) => [String(g.id), g]));
41
+ const joinedOrder = joinedGoals(goals).map((g) => String(g.id));
42
+ const groups = new Map();
43
+ for (const t of list) {
44
+ const key = t.goal_id == null ? null : String(t.goal_id);
45
+ if (!groups.has(key)) {
46
+ groups.set(key, {
47
+ goal_id: key,
48
+ title: key == null ? OTHER_WORK : goalName(key, byId),
49
+ joined: key != null && joinedOrder.includes(key),
50
+ rows: [],
51
+ });
52
+ }
53
+ groups.get(key).rows.push(t);
54
+ }
55
+ const all = [...groups.values()];
56
+ const joined = joinedOrder.map((id) => groups.get(id)).filter(Boolean);
57
+ const others = all.filter((g) => g.goal_id != null && !g.joined);
58
+ const other = groups.get(null);
59
+ return [...joined, ...others, ...(other ? [other] : [])];
60
+ }
61
+
62
+ // The "Your goals" lines above the table: each joined goal and how many tasks in
63
+ // it are claimable for this builder right now (from the FULL filtered set, not
64
+ // the capped card rows, so a count never under-reports). Empty when the builder
65
+ // has joined no open goal; the table below still lists everything.
66
+ function yourGoals(goals, claimable) {
67
+ const counts = new Map();
68
+ for (const t of Array.isArray(claimable) ? claimable : []) {
69
+ if (t.goal_id == null) continue;
70
+ const k = String(t.goal_id);
71
+ counts.set(k, (counts.get(k) || 0) + 1);
72
+ }
73
+ const joined = joinedGoals(goals);
74
+ return {
75
+ goals: joined.slice(0, JOINED_SHOWN).map((g) => ({
76
+ id: String(g.id),
77
+ title: g.title || `Goal ${g.id}`,
78
+ claimable: counts.get(String(g.id)) || 0,
79
+ })),
80
+ more: Math.max(0, joined.length - JOINED_SHOWN),
81
+ };
82
+ }
83
+
84
+ // Goal-first ordering for the capped card: rows in a goal the builder joined come
85
+ // first, everything else after, each half keeping the server's order. Without it
86
+ // the card's top rows were the board's top rows and a builder's own goals could
87
+ // show "5 to claim" above a table holding none of them. A stable partition, so it
88
+ // only moves rows, never drops one.
89
+ function joinedFirst(rows, goals) {
90
+ const list = Array.isArray(rows) ? rows : [];
91
+ const joined = new Set(joinedGoals(goals).map((g) => String(g.id)));
92
+ const mine = [];
93
+ const rest = [];
94
+ for (const t of list) (t.goal_id != null && joined.has(String(t.goal_id)) ? mine : rest).push(t);
95
+ return mine.concat(rest);
96
+ }
97
+
98
+ function claimableWord(n) {
99
+ return n === 0 ? 'nothing to claim right now' : `${n} to claim`;
100
+ }
101
+
102
+ module.exports = { OTHER_WORK, joinedGoals, joinedFirst, groupByGoal, yourGoals, claimableWord };
@@ -233,23 +233,30 @@ function whatItIs(t) {
233
233
  // Ids are emitted as hall links (renders as a clean #N) — a bare "#NNN" in a
234
234
  // relayed chat message autolinks to GitHub and 404s (CLAUDE.md ref rule).
235
235
  const surface = require('./surface');
236
+ const startGoals = require('./start-goals');
236
237
  const HALL_TASK = `${hallBase()}#/task/`;
237
- function claimableTable(rows, rewardFromServer) {
238
+ function claimableTable(rows, rewardFromServer, goals) {
238
239
  // On a cost-plus-only instance the Reward column is an advertised estimate that
239
240
  // is never paid — say so in the header so the column can't read as a promise,
240
241
  // and footnote what IS paid below the table (task 1002469).
241
242
  const perTask = rewardPolicy(rewardFromServer).perTaskCredit;
242
243
  const rewardHead = perTask ? 'Reward' : 'Reward (est.)';
243
244
  const headers = ['#', 'Version', ROLE_AXIS_LABEL, rewardHead, 'Est.', 'What it is'];
244
- const body = rows.map((t) => [
245
+ const rowCells = (t) => [
245
246
  surface.taskRef(t.id, HALL_TASK),
246
247
  cell(t.version_id || '?'),
247
248
  cell(roleLabel(t) || '—'),
248
249
  `${t.credits_reward ?? 0}c`,
249
250
  estimateShort(t),
250
251
  cell(whatItIs(t)),
251
- ]);
252
- const out = [surface.table(headers, body)];
252
+ ];
253
+ // task 1002852: one table per goal, each under its goal's name; goal-less work
254
+ // last under "Other work". An empty goal list (a failed read) still groups by id.
255
+ const out = [];
256
+ for (const g of startGoals.groupByGoal(rows, goals)) {
257
+ if (out.length) out.push('');
258
+ out.push(surface.strong(g.title) + (g.joined ? ' · your goal' : ''), surface.table(headers, g.rows.map(rowCells)));
259
+ }
253
260
  const cpNote = costPlusOnlyNote(rewardFromServer);
254
261
  if (cpNote) out.push('', surface.em(cpNote));
255
262
  return out.join('\n');
@@ -374,6 +381,7 @@ const WIDGET_STYLE = `<style>
374
381
  .bs-thead{text-align:left;color:var(--color-text-tertiary);font-size:12px;}
375
382
  .bs-th{padding:0 6px 4px;font-weight:400;}
376
383
  .bs-tr{cursor:pointer;}
384
+ .bs-group td{padding:10px 0 4px;font-size:12px;font-weight:500;color:var(--color-text-secondary);}
377
385
  .bs-td{padding:7px 6px;border-top:0.5px solid var(--color-border-tertiary);}
378
386
  .bs-id{color:var(--color-text-secondary);font-family:var(--font-mono);font-size:12px;}
379
387
  .bs-nowrap{white-space:nowrap;}
@@ -384,7 +392,7 @@ const WIDGET_STYLE = `<style>
384
392
  .bs-foot{font-size:13px;color:var(--color-text-tertiary);margin-top:12px;}
385
393
  </style>`;
386
394
 
387
- function renderWidget({ builder, claims, claimable, total, streak, recommendations, header, routingByPreference, preferred, filters, readyToClose, needsRebaseTasks }) {
395
+ function renderWidget({ builder, claims, claimable, total, streak, recommendations, header, routingByPreference, preferred, filters, readyToClose, needsRebaseTasks, goals, yourGoals }) {
388
396
  const b = builder || {};
389
397
  // ADR 0120 part 5: the owed-rebase gate warning, rendered at the top of the
390
398
  // card (before status/holdings) since it blocks new claims. '' when none owed.
@@ -443,7 +451,7 @@ function renderWidget({ builder, claims, claimable, total, streak, recommendatio
443
451
 
444
452
  // rows — click to claim. Columns: # / Version / Type / Reward /
445
453
  // Est. / What it is (the work lane is its own column, not baked into the title).
446
- const rows = claimable.map((t) => {
454
+ const taskRow = (t) => {
447
455
  const claimMsg = jsq(`Claim task ${t.id}`);
448
456
  const titleAttr = hesc(`Claim #${t.id}: ${t.title || ''}`);
449
457
  const d = t.discipline;
@@ -458,7 +466,16 @@ function renderWidget({ builder, claims, claimable, total, streak, recommendatio
458
466
  + TD(hesc(estimateShort(t)), 'bs-sub')
459
467
  + TD(hesc(t.title || ''), 'bs-ellip')
460
468
  + `</tr>`;
461
- }).join('');
469
+ };
470
+ // task 1002852: the same rows, grouped under each task's goal ("Other work" last).
471
+ const rows = startGoals.groupByGoal(claimable, goals).map((g) =>
472
+ `<tr class="bs-group"><td colspan="6">${hesc(g.title)}${g.joined ? '<span class="bs-sub"> · your goal</span>' : ''}</td></tr>`
473
+ + g.rows.map(taskRow).join('')).join('');
474
+ // task 1002852: the goals this builder joined, ABOVE the table (never instead of it).
475
+ const goalsBlock = yourGoals.goals.length
476
+ ? `<div class="bs-menuh">Your goals</div>${yourGoals.goals.map((g) =>
477
+ `<div class="bs-line"><i class="ti ti-target" aria-hidden="true"></i>${hesc(clip(g.title, 60))}<span class="bs-dot"> · </span><span class="bs-sub">${hesc(startGoals.claimableWord(g.claimable))}</span></div>`).join('')}${yourGoals.more ? `<div class="bs-line bs-sub">and ${yourGoals.more} more</div>` : ''}`
478
+ : '';
462
479
 
463
480
  const menu = claimable.length
464
481
  ? `<table class="bs-table">
@@ -495,6 +512,7 @@ function renderWidget({ builder, claims, claimable, total, streak, recommendatio
495
512
  ${statusRow}
496
513
  ${holdings}
497
514
  ${closeBanner}
515
+ ${goalsBlock}
498
516
  <div class="bs-menuh">Claim one to start — ${total || claimable.length} ready for you right now${matched}${fNote}</div>
499
517
  ${menu}
500
518
  <div class="bs-foot">Click a task to claim it${moreLink}${crossLane}</div>
@@ -679,9 +697,9 @@ async function main() {
679
697
  // endpoint fails, that widget degrades to "N/A" rather than killing
680
698
  // /builder-start.
681
699
  const api = await cliClient();
682
- let me, r, closeRes, progressRes, costRes, recentRes, inboxRes, blockersRes;
700
+ let me, r, closeRes, progressRes, costRes, recentRes, inboxRes, blockersRes, goalsRes;
683
701
  try {
684
- [me, r, closeRes, progressRes, costRes, recentRes, inboxRes, blockersRes] = await Promise.all([
702
+ [me, r, closeRes, progressRes, costRes, recentRes, inboxRes, blockersRes, goalsRes] = await Promise.all([
685
703
  api.me.getMe(),
686
704
  api.tasks.getTasksClaimable({ query: claimableQuery }),
687
705
  api.versions.getVersionsCloseSuggestions(),
@@ -690,6 +708,8 @@ async function main() {
690
708
  api.public.getPublicRecentShipped(),
691
709
  api.inbox.getInbox({ query: { status: 'open' } }),
692
710
  api.blockers.getBlockers(),
711
+ // task 1002852: best-effort — a failed goals read leaves the table flat-grouped by id.
712
+ api.goals.getGoals({ query: { status: 'open' } }).catch(() => ({ ok: false })),
693
713
  ]);
694
714
  } catch (err) {
695
715
  // A fresh-clone instance that was never brought up refuses the connection.
@@ -741,7 +761,10 @@ async function main() {
741
761
  const filtered = routingByPreference
742
762
  ? rawClaimable.filter((t) => preferred.includes(t.discipline))
743
763
  : rawClaimable;
744
- const claimable = filtered.slice(0, limit);
764
+ // task 1002852: goal-first. The open goals (with the server's viewer_is_member)
765
+ // group the table and put the builder's joined goals' rows ahead of the cap.
766
+ const goals = goalsRes && goalsRes.ok && Array.isArray(goalsRes.data?.goals) ? goalsRes.data.goals : [];
767
+ const claimable = startGoals.joinedFirst(filtered, goals).slice(0, limit);
745
768
 
746
769
  // "You might enjoy" — up to 3 claimable tasks outside the builder's lanes.
747
770
  // Computed from the FULL set so it can reach across disciplines. Suppressed
@@ -758,6 +781,7 @@ async function main() {
758
781
  ? closeRes.data.suggestions
759
782
  : [];
760
783
  const readyToClose = closeSuggestions.filter((s) => s.ready);
784
+ const yourGoals = startGoals.yourGoals(goals, filtered);
761
785
 
762
786
  // Header widget data (each independently optional).
763
787
  const progress = progressRes.ok && Array.isArray(progressRes.data?.progress) ? progressRes.data.progress : null;
@@ -781,6 +805,7 @@ async function main() {
781
805
  me: me.data,
782
806
  claimable,
783
807
  recommendations,
808
+ your_goals: yourGoals.goals,
784
809
  discipline_routing: {
785
810
  preferred_disciplines: preferred,
786
811
  applied: routingByPreference,
@@ -836,6 +861,8 @@ async function main() {
836
861
  filters: { version, discipline, fit },
837
862
  readyToClose,
838
863
  needsRebaseTasks,
864
+ goals,
865
+ yourGoals,
839
866
  }) + '\n');
840
867
  return;
841
868
  }
@@ -951,11 +978,18 @@ async function main() {
951
978
  fit && `fit=${fit}`,
952
979
  ].filter(Boolean);
953
980
  const filterNote = filterBits.length ? ` (${filterBits.join(', ')})` : '';
981
+ // task 1002852: the goals this builder joined, above the table (never instead of it).
982
+ if (yourGoals.goals.length) {
983
+ console.log(surface.strong('Your goals'));
984
+ for (const g of yourGoals.goals) console.log(` ${clip(g.title, 60)} · ${startGoals.claimableWord(g.claimable)}`);
985
+ if (yourGoals.more) console.log(` and ${yourGoals.more} more`);
986
+ console.log('');
987
+ }
954
988
  console.log(`${surface.strong('Claim one to start')} — ${filtered.length} ready for you right now${matched}${filterNote}:`);
955
989
  console.log('');
956
990
  if (claimable.length) {
957
991
  const cardRows = claimable.slice(0, CARD_ROWS);
958
- console.log(claimableTable(cardRows, me.ok ? me.data?.reward : null));
992
+ console.log(claimableTable(cardRows, me.ok ? me.data?.reward : null, goals));
959
993
  const moreN = filtered.length - cardRows.length;
960
994
  if (moreN > 0) {
961
995
  console.log('');
@@ -1012,6 +1046,8 @@ async function main() {
1012
1046
  filters: { version, discipline, fit },
1013
1047
  readyToClose,
1014
1048
  needsRebaseTasks,
1049
+ goals,
1050
+ yourGoals,
1015
1051
  })
1016
1052
  : null;
1017
1053
  const markdown = captureLog(emitMarkdownCard).join('\n');
package/src/module-api.js CHANGED
@@ -75,7 +75,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
75
75
  // MAJOR (see allowBoxScope below): passes the request through untouched.
76
76
  function deprecatedNoopMiddleware(_req, _res, next) { next(); }
77
77
 
78
- const CORE_VERSION = '1.21.86'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
78
+ const CORE_VERSION = '1.21.87'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
79
79
 
80
80
  // A namespaced logger so a module's log lines are attributable + consistent.
81
81
  // Usage: const log = api.logger('discord'); log.info('mounted');
@@ -0,0 +1,90 @@
1
+ // tests/start_goal_first.mjs — /builder-start leads with the builder's goals
2
+ // (task 1002852).
3
+ //
4
+ // The owner's rules (2026-10-08): the joined goals sit ABOVE the claimable table
5
+ // and never replace it, and a task with no goal is listed under "Other work".
6
+ // These drive the pure helpers in scripts/gds/start-goals.js and the real table
7
+ // renderer start.js uses, so the property that matters — every claimable row is
8
+ // still listed, exactly once — is checked on what the builder actually sees.
9
+
10
+ import { strict as assert } from 'node:assert';
11
+ import { createRequire } from 'node:module';
12
+ import { makeRunner } from './helpers.mjs';
13
+
14
+ const require = createRequire(import.meta.url);
15
+ const { test, summary } = makeRunner();
16
+ const sg = require('../scripts/gds/start-goals.js');
17
+
18
+ const GOALS = [
19
+ { id: 10, title: 'Alpha', status: 'open', viewer_is_member: false },
20
+ { id: 20, title: 'Mine', status: 'open', viewer_is_member: true },
21
+ { id: 30, title: 'Done one', status: 'achieved', viewer_is_member: true },
22
+ { id: 40, title: 'Also mine', status: 'open', viewer_is_member: true },
23
+ ];
24
+ const ROWS = [
25
+ { id: 1, goal_id: 10, title: 'a1' },
26
+ { id: 2, goal_id: null, title: 'loose' },
27
+ { id: 3, goal_id: 20, title: 'm1' },
28
+ { id: 4, goal_id: 99, title: 'unknown goal' },
29
+ { id: 5, goal_id: 20, title: 'm2' },
30
+ ];
31
+
32
+ await test('joined goals are the open ones the server says the viewer is a member of', () => {
33
+ assert.deepEqual(sg.joinedGoals(GOALS).map((g) => g.id), [20, 40]);
34
+ });
35
+
36
+ await test('groups: joined goals first, then other goals, then "Other work" last; every row exactly once', () => {
37
+ const groups = sg.groupByGoal(ROWS, GOALS);
38
+ assert.deepEqual(groups.map((g) => g.title), ['Mine', 'Alpha', 'Goal 99', sg.OTHER_WORK]);
39
+ assert.deepEqual(groups[0].rows.map((r) => r.id), [3, 5], 'server order kept inside a group');
40
+ assert.equal(groups[0].joined, true);
41
+ assert.equal(groups[3].goal_id, null);
42
+ const ids = groups.flatMap((g) => g.rows.map((r) => r.id)).sort();
43
+ assert.deepEqual(ids, [1, 2, 3, 4, 5]);
44
+ });
45
+
46
+ await test('with no goal list (a failed read) nothing is hidden: goals by id, goal-less under Other work', () => {
47
+ const groups = sg.groupByGoal(ROWS, []);
48
+ assert.equal(groups.flatMap((g) => g.rows).length, ROWS.length);
49
+ assert.equal(groups.at(-1).title, sg.OTHER_WORK);
50
+ assert.ok(groups.every((g) => !g.joined));
51
+ });
52
+
53
+ await test('joinedFirst moves the joined goals\' rows ahead of the cap and drops nothing', () => {
54
+ const out = sg.joinedFirst(ROWS, GOALS);
55
+ assert.deepEqual(out.map((r) => r.id), [3, 5, 1, 2, 4]);
56
+ assert.deepEqual(sg.joinedFirst(ROWS, []).map((r) => r.id), [1, 2, 3, 4, 5], 'no joined goal: order unchanged');
57
+ });
58
+
59
+ await test('"Your goals" counts claimable work per joined goal from the full set', () => {
60
+ const y = sg.yourGoals(GOALS, ROWS);
61
+ assert.deepEqual(y.goals, [
62
+ { id: '20', title: 'Mine', claimable: 2 },
63
+ { id: '40', title: 'Also mine', claimable: 0 },
64
+ ]);
65
+ assert.equal(y.more, 0);
66
+ assert.equal(sg.claimableWord(0), 'nothing to claim right now');
67
+ assert.equal(sg.claimableWord(2), '2 to claim');
68
+ });
69
+
70
+ await test('a builder in no goal gets no goals list, and the table still lists everything', () => {
71
+ const none = GOALS.map((g) => ({ ...g, viewer_is_member: false }));
72
+ assert.deepEqual(sg.yourGoals(none, ROWS).goals, []);
73
+ assert.equal(sg.groupByGoal(ROWS, none).flatMap((g) => g.rows).length, ROWS.length);
74
+ });
75
+
76
+ await test('start.js renders "Your goals" ABOVE the table and groups the table by goal (the wiring)', () => {
77
+ const fs = require('node:fs');
78
+ const src = fs.readFileSync(require.resolve('../scripts/gds/start.js'), 'utf8');
79
+ const goalsAt = src.indexOf("surface.strong('Your goals')");
80
+ const tableAt = src.indexOf("surface.strong('Claim one to start')");
81
+ assert.ok(goalsAt > 0 && goalsAt < tableAt, 'markdown: goals before the table');
82
+ const wGoals = src.indexOf('${goalsBlock}');
83
+ const wMenu = src.indexOf('<div class="bs-menuh">Claim one to start');
84
+ assert.ok(wGoals > 0 && wGoals < wMenu, 'widget: goals before the table');
85
+ assert.match(src, /claimableTable\(cardRows, [^)]*, goals\)/, 'the markdown table is grouped');
86
+ assert.equal((src.match(/\n\s+goals,\n\s+yourGoals,\n/g) || []).length, 2, 'both widget calls carry the goals');
87
+ assert.match(src, /startGoals\.joinedFirst\(filtered, goals\)\.slice\(0, limit\)/);
88
+ });
89
+
90
+ summary();