@bongos/core 1.19.279 → 1.19.281

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.
@@ -0,0 +1,99 @@
1
+ // goals-lib.js — the Goals page's PURE presentational helpers, exposed as
2
+ // window.OTBGoalsLib. Carved out of the oversized goals-page.js (task 1003135;
3
+ // the fitness excess-lines ratchet). Everything here is a pure function over
4
+ // its arguments (or a constant) — no `state`, no DOM reads — so it can move
5
+ // without dragging the page's IIFE state along. Loaded by goals.html BEFORE
6
+ // goals-page.js (the task-lib.js / hall-kit.js pattern, tasks 1002463 / 2171).
7
+ (function () {
8
+ 'use strict';
9
+
10
+ // Settled task statuses (paid or awaiting land) vs terminal "deliberately
11
+ // cut" states — neither work-to-do nor done. CUT is excluded from the todo
12
+ // list and the conflict scan the same way migration 017 keeps them out of
13
+ // version_progress ("abandoned is a terminal state (deliberately cut), not
14
+ // pending work"). Without this, an abandoned task shows up under a goal's
15
+ // "Work to do" as if it were still queued.
16
+ const DONE_STATUSES = new Set(['completed', 'confirmed', 'shipped']);
17
+ const CUT_STATUSES = new Set(['abandoned', 'dropped']);
18
+ // Genuinely-open work under a goal: not done, not cut → {backlog, ready,
19
+ // active, blocked}.
20
+ const isTodoStatus = (s) => !DONE_STATUSES.has(s) && !CUT_STATUSES.has(s);
21
+
22
+ // The criteria progress ring (satisfied/total), green once achieved.
23
+ function ring(satisfied, total, achieved, size = 42) {
24
+ const pct = total > 0 ? satisfied / total : (achieved ? 1 : 0);
25
+ const r = 16;
26
+ const circ = 2 * Math.PI * r;
27
+ const dash = (pct * circ).toFixed(1);
28
+ const gap = (circ - pct * circ).toFixed(1);
29
+ const stroke = achieved ? 'var(--ok)' : 'var(--accent)';
30
+ return `<svg class="goal-ring" width="${size}" height="${size}" viewBox="0 0 42 42" role="img" aria-label="${satisfied} of ${total} criteria satisfied">
31
+ <circle cx="21" cy="21" r="${r}" fill="none" stroke="var(--rule-soft)" stroke-width="4"></circle>
32
+ <circle cx="21" cy="21" r="${r}" fill="none" stroke="${stroke}" stroke-width="4" stroke-linecap="round" stroke-dasharray="${dash} ${gap}" transform="rotate(-90 21 21)"></circle>
33
+ <text x="21" y="25" text-anchor="middle" class="goal-ring__label">${satisfied}/${total}</text>
34
+ </svg>`;
35
+ }
36
+
37
+ function memberName(m) {
38
+ return m.display_name || (m.github_login ? `@${m.github_login}` : `builder #${m.builder_id}`);
39
+ }
40
+
41
+ // ---- task-readiness rollup (hall redesign task 1002439) -------------------
42
+ // Dependencies are a TASK property; a goal is only "blocked" in the derived
43
+ // sense that all its actionable tasks wait on upstream tasks (the Tasks page,
44
+ // not this one, owns the actual task→task graph). So the Goals page rolls each
45
+ // goal's tasks into these buckets:
46
+ // done — settled (shipped/confirmed/completed)
47
+ // active — a live claim ("in progress")
48
+ // ready — claimable now (deps met + promoted)
49
+ // blocked — backlog with ≥1 unshipped dependency
50
+ // backlog — backlog, no unmet deps (just not started/promoted)
51
+ function readinessOf(t) {
52
+ const s = t.status;
53
+ if (DONE_STATUSES.has(s)) return 'done';
54
+ if (CUT_STATUSES.has(s)) return 'cut';
55
+ if (s === 'active') return 'active';
56
+ if (s === 'ready') return 'ready';
57
+ if (s === 'backlog' && (t.blocked_by || []).some((d) => !d.satisfied)) return 'blocked';
58
+ return 'backlog';
59
+ }
60
+ const EMPTY_ROLLUP = { done: 0, active: 0, ready: 0, blocked: 0, backlog: 0, cut: 0, total: 0, nextReady: null };
61
+
62
+ // task 1002439 — a goal row's readiness rollup line (only the non-zero buckets).
63
+ const ROLL_DOTS = [
64
+ ['ready', 'ready', 'var(--ok)'],
65
+ ['active', 'in progress', 'var(--info)'],
66
+ ['blocked', 'blocked', 'var(--terracotta)'],
67
+ ['backlog', 'backlog', 'var(--ink-faint)'],
68
+ ];
69
+ function goalRollLine(r) {
70
+ const parts = ROLL_DOTS.filter(([k]) => r[k] > 0)
71
+ .map(([k, label, c]) => `<span class="gr-d"><i style="background:${c}"></i><b>${r[k]}</b> ${label}</span>`);
72
+ return parts.length ? `<div class="gr-roll">${parts.join('')}</div>` : '';
73
+ }
74
+
75
+ // Free-text search over a goal's todo list (work.js:261-274, minus the
76
+ // goal-title match — a single goal makes that redundant). '#N', 'N' and a
77
+ // bare number all match the id.
78
+ function matchesTodoSearch(t, q) {
79
+ if (!q) return true;
80
+ const needle = q.replace(/^#/, '');
81
+ if (!needle) return true;
82
+ if (String(t.id).includes(needle)) return true;
83
+ if ((t.title || '').toLowerCase().includes(needle)) return true;
84
+ return false;
85
+ }
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));
92
+
93
+ window.OTBGoalsLib = {
94
+ DONE_STATUSES, CUT_STATUSES, isTodoStatus,
95
+ ring, memberName,
96
+ readinessOf, EMPTY_ROLLUP, goalRollLine,
97
+ matchesTodoSearch, msOf, taskActivityMs,
98
+ };
99
+ })();
@@ -35,13 +35,18 @@
35
35
  // empty/skeleton states — adopted here by task 2171.
36
36
  const { makePager, paginate, wirePager, pagerBarHtml, makeTabs, makeFilterBar,
37
37
  emptyStateHtml, skeletonRows } = window.OTBKit;
38
+ // This page's pure presentational helpers (goals-lib.js, task 1003135 — carved
39
+ // out of this oversized file; the fitness excess-lines ratchet). Destructured
40
+ // so every call site below reads exactly as before.
41
+ const { DONE_STATUSES, CUT_STATUSES, isTodoStatus, ring, memberName,
42
+ readinessOf, EMPTY_ROLLUP, goalRollLine, matchesTodoSearch,
43
+ msOf, taskActivityMs } = window.OTBGoalsLib;
38
44
  const API = '/api/bongos';
39
45
  // Generated typed client (task 1996 / R09), served as window.BongosClient by
40
46
  // /lib/bongos-client.js. baseUrl:'' passes the full same-origin paths this file
41
47
  // builds through unchanged; result-mode ({ status, ok, data }) preserves control flow 1:1.
42
48
  const api = window.BongosClient.createClient({ baseUrl: '', credentials: 'same-origin', throwOnError: false });
43
49
 
44
- const DONE_STATUSES = new Set(['completed', 'confirmed', 'shipped']);
45
50
  // task 1888 — the Work tables and the conflicts list render capped; the pager
46
51
  // under each expands to the full set.
47
52
  const CONFLICT_CAP = 3;
@@ -59,15 +64,6 @@
59
64
  todo: makePager('goal-todo', 'otb.goals.todoPerPage'),
60
65
  done: makePager('goal-done', 'otb.goals.donePerPage'),
61
66
  };
62
- // Terminal "deliberately cut" states — neither work-to-do nor done. Excluded
63
- // from the todo list and the conflict scan the same way migration 017 keeps
64
- // them out of version_progress ("abandoned is a terminal state (deliberately
65
- // cut), not pending work"). Without this, an abandoned task shows up under a
66
- // goal's "Work to do" as if it were still queued.
67
- const CUT_STATUSES = new Set(['abandoned', 'dropped']);
68
- // Genuinely-open work under a goal: not done, not cut → {backlog, ready,
69
- // active, blocked}.
70
- const isTodoStatus = (s) => !DONE_STATUSES.has(s) && !CUT_STATUSES.has(s);
71
67
  // Record deep-links (#/task|idea|blocker/N) are owned by the shared overlay
72
68
  // (task-detail.js). This page must NOT treat them as a route change (Bug 2).
73
69
  const DEEPLINK_RE = /^#\/(?:task|idea|blocker)\/\d+$/;
@@ -148,44 +144,7 @@
148
144
  return `<span class="goal-badge ${cls}">${escapeHtml(status)}</span>`;
149
145
  }
150
146
 
151
- function ring(satisfied, total, achieved, size = 42) {
152
- const pct = total > 0 ? satisfied / total : (achieved ? 1 : 0);
153
- const r = 16;
154
- const circ = 2 * Math.PI * r;
155
- const dash = (pct * circ).toFixed(1);
156
- const gap = (circ - pct * circ).toFixed(1);
157
- const stroke = achieved ? 'var(--ok)' : 'var(--accent)';
158
- return `<svg class="goal-ring" width="${size}" height="${size}" viewBox="0 0 42 42" role="img" aria-label="${satisfied} of ${total} criteria satisfied">
159
- <circle cx="21" cy="21" r="${r}" fill="none" stroke="var(--rule-soft)" stroke-width="4"></circle>
160
- <circle cx="21" cy="21" r="${r}" fill="none" stroke="${stroke}" stroke-width="4" stroke-linecap="round" stroke-dasharray="${dash} ${gap}" transform="rotate(-90 21 21)"></circle>
161
- <text x="21" y="25" text-anchor="middle" class="goal-ring__label">${satisfied}/${total}</text>
162
- </svg>`;
163
- }
164
-
165
- function memberName(m) {
166
- return m.display_name || (m.github_login ? `@${m.github_login}` : `builder #${m.builder_id}`);
167
- }
168
-
169
- // ---- task-readiness rollup (hall redesign task 1002439) --------------------
170
- // Dependencies are a TASK property; a goal is only "blocked" in the derived
171
- // sense that all its actionable tasks wait on upstream tasks (the Tasks page,
172
- // not this one, owns the actual task→task graph). So the Goals page rolls each
173
- // goal's tasks into these buckets:
174
- // done — settled (shipped/confirmed/completed)
175
- // active — a live claim ("in progress")
176
- // ready — claimable now (deps met + promoted)
177
- // blocked — backlog with ≥1 unshipped dependency
178
- // backlog — backlog, no unmet deps (just not started/promoted)
179
- function readinessOf(t) {
180
- const s = t.status;
181
- if (DONE_STATUSES.has(s)) return 'done';
182
- if (CUT_STATUSES.has(s)) return 'cut';
183
- if (s === 'active') return 'active';
184
- if (s === 'ready') return 'ready';
185
- if (s === 'backlog' && (t.blocked_by || []).some((d) => !d.satisfied)) return 'blocked';
186
- return 'backlog';
187
- }
188
- const EMPTY_ROLLUP = { done: 0, active: 0, ready: 0, blocked: 0, backlog: 0, cut: 0, total: 0, nextReady: null };
147
+ // ring / memberName / readinessOf / EMPTY_ROLLUP → goals-lib.js (task 1003135).
189
148
  const rollupOf = (goalId) => state.rollups.get(String(goalId)) || EMPTY_ROLLUP;
190
149
 
191
150
  // ---- goal ordering (task 1002965) -----------------------------------------
@@ -197,8 +156,6 @@
197
156
  // falling back to the goal row's own updated_at when the goal
198
157
  // has no tasks at all, so a task-less goal still orders sanely
199
158
  // instead of sinking to the bottom forever.
200
- const msOf = (iso) => { const t = iso ? Date.parse(iso) : NaN; return Number.isNaN(t) ? 0 : t; };
201
- const taskActivityMs = (t) => Math.max(msOf(t.updated_at), msOf(t.shipped_at));
202
159
  const goalCreatedMs = (g) => msOf(g.created_at);
203
160
  const goalActivityMs = (g) => state.activity.get(String(g.id)) || msOf(g.updated_at);
204
161
 
@@ -271,6 +228,40 @@
271
228
  }
272
229
  }
273
230
 
231
+ // task 1003135 — the Owner dropdown. 'Goal owner' is goals.created_by (the
232
+ // fixed authority, migration 160), returned by GET /goals as a raw builder id;
233
+ // labels come from the directory the page already lazily caches (loadDirectory,
234
+ // task 1877), same label idiom as the invite dropdown. created_by is NULLABLE
235
+ // (system-attributed — e.g. the migration-160 '<version> — general' catch-alls),
236
+ // hence the explicit 'system / unattributed' option. Mirrors
237
+ // populateVersionFilter above; fired after boot's fetch resolves (async because
238
+ // of the directory fetch — fire-and-forget, the select fills when ready).
239
+ async function populateOwnerFilter() {
240
+ const sel = $('#f-goal-owner');
241
+ if (!sel) return;
242
+ const ownerIds = Array.from(new Set(
243
+ state.goals.filter((g) => g.created_by != null).map((g) => String(g.created_by))));
244
+ const hasSystem = state.goals.some((g) => g.created_by == null);
245
+ const dir = ownerIds.length ? await loadDirectory() : [];
246
+ const byId = new Map((dir || []).map((b) => [String(b.id), b]));
247
+ const label = (id) => {
248
+ const b = byId.get(id);
249
+ return b ? (b.display_name || '@' + b.github_login) : `builder #${id}`;
250
+ };
251
+ for (const id of ownerIds.sort((a, b) => label(a).localeCompare(label(b)))) {
252
+ const opt = document.createElement('option');
253
+ opt.value = id;
254
+ opt.textContent = label(id);
255
+ sel.appendChild(opt);
256
+ }
257
+ if (hasSystem) {
258
+ const opt = document.createElement('option');
259
+ opt.value = 'none';
260
+ opt.textContent = 'system / unattributed';
261
+ sel.appendChild(opt);
262
+ }
263
+ }
264
+
274
265
  // task 1760 — priority is a WITHIN-version ordering, so the drag affordance
275
266
  // only makes sense once the grid is narrowed to exactly one version (the
276
267
  // "any" filter mixes versions and dragging across them would be meaningless).
@@ -292,6 +283,18 @@
292
283
  const ver = $('#f-goal-version')?.value || '';
293
284
  const status = $('#f-goal-status')?.value || '';
294
285
  const mineOnly = $('#f-goal-mine')?.value === 'mine';
286
+ // task 1003135 — free-text search (goal title OR id; '#N', 'N' and a bare
287
+ // number all match — the id is already rendered on the row, task 1002163)
288
+ // and the Owner dropdown ('none' = created_by NULL, system-attributed).
289
+ // Reads the DOM ids directly, matching this function's existing style.
290
+ const q = ($('#f-goals-search')?.value || '').trim().toLowerCase();
291
+ const needle = q.replace(/^#/, '');
292
+ const owner = $('#f-goal-owner')?.value || '';
293
+ const matchesSearch = (g) => !needle
294
+ || String(g.id).includes(needle)
295
+ || (g.title || '').toLowerCase().includes(needle);
296
+ const matchesOwner = (g) => !owner
297
+ || (owner === 'none' ? g.created_by == null : String(g.created_by) === owner);
295
298
  const myId = selfId();
296
299
  const iAmMember = (g) => {
297
300
  const d = state.details.get(String(g.id));
@@ -300,22 +303,11 @@
300
303
  return state.goals.filter((g) =>
301
304
  (!ver || g.version_id === ver) &&
302
305
  (!status || g.status === status) &&
306
+ matchesSearch(g) &&
307
+ matchesOwner(g) &&
303
308
  (!mineOnly || iAmMember(g)));
304
309
  }
305
310
 
306
- // task 1002439 — a goal row's readiness rollup line (only the non-zero buckets).
307
- const ROLL_DOTS = [
308
- ['ready', 'ready', 'var(--ok)'],
309
- ['active', 'in progress', 'var(--info)'],
310
- ['blocked', 'blocked', 'var(--terracotta)'],
311
- ['backlog', 'backlog', 'var(--ink-faint)'],
312
- ];
313
- function goalRollLine(r) {
314
- const parts = ROLL_DOTS.filter(([k]) => r[k] > 0)
315
- .map(([k, label, c]) => `<span class="gr-d"><i style="background:${c}"></i><b>${r[k]}</b> ${label}</span>`);
316
- return parts.length ? `<div class="gr-roll">${parts.join('')}</div>` : '';
317
- }
318
-
319
311
  // task 1002439 — one goal, rendered as a rollup row: criteria ring · title +
320
312
  // meta + readiness rollup · one next action (claim the next ready task, drill
321
313
  // into what's blocking, or a review/achieved note).
@@ -515,15 +507,6 @@
515
507
  const el = $('#f-goal-search');
516
508
  return el ? el.value.trim().toLowerCase() : '';
517
509
  }
518
- function matchesTodoSearch(t, q) {
519
- if (!q) return true;
520
- const needle = q.replace(/^#/, '');
521
- if (!needle) return true;
522
- if (String(t.id).includes(needle)) return true;
523
- if ((t.title || '').toLowerCase().includes(needle)) return true;
524
- return false;
525
- }
526
-
527
510
  // Copy-prompt (work.js:454-503). deHashRefs stops a bare #NNN autolinking to
528
511
  // GitHub in a pasted session; the prompt points the builder at the hall task URL.
529
512
  async function copyPromptForTask(taskId, btn) {
@@ -1810,6 +1793,9 @@
1810
1793
  const gres = await getJSON(`${API}/goals`);
1811
1794
  state.goals = gres.goals || [];
1812
1795
  populateVersionFilter();
1796
+ // task 1003135 — fire-and-forget: the Owner options fill when the (cached)
1797
+ // directory resolves; boot never waits on a label lookup.
1798
+ populateOwnerFilter().catch(() => {});
1813
1799
  // Prefetch details (ring + members on the cards). Sequentially-parallel,
1814
1800
  // failure-tolerant: a card without detail still renders.
1815
1801
  // task 1002439 — also fold in per-goal task-readiness rollups (one request
@@ -1848,12 +1834,21 @@
1848
1834
  // taken by the goal-detail To-do bar further down this file).
1849
1835
  let savedGoalSort = null;
1850
1836
  try { savedGoalSort = window.localStorage.getItem(GOAL_SORT_PREF); } catch (_) { /* private mode */ }
1837
+ // task 1003135 — Search declared on the kit spec (live 'input' listener comes
1838
+ // free, hall-kit.js:273; no debounce needed, the match is pure client-side over
1839
+ // the already-loaded state.goals). Id is f-goals-search — f-goal-search is
1840
+ // already taken by the goal-detail To-do bar further down this file. Owner
1841
+ // options are filled after boot's fetch (populateOwnerFilter), the
1842
+ // populateVersionFilter pattern. No URL riding (url defaults off here and this
1843
+ // page is deliberately url:false — it owns the #/goal/N hash route).
1851
1844
  makeFilterBar({
1852
1845
  mount: $('#goals-filters'),
1846
+ search: { id: 'f-goals-search', label: 'Search', placeholder: 'goal # or name…' },
1853
1847
  primary: [
1854
1848
  { id: 'f-goal-version', label: 'Version', options: [{ value: '', label: '— any —' }] },
1855
1849
  { id: 'f-goal-status', label: 'Status', options: [{ value: '', label: '— any —' }, 'open', 'achieved', 'archived'] },
1856
1850
  { id: 'f-goal-mine', label: 'Membership', options: [{ value: '', label: 'All goals' }, { value: 'mine', label: 'My goals' }] },
1851
+ { id: 'f-goal-owner', label: 'Owner', options: [{ value: '', label: '— any —' }] },
1857
1852
  { id: 'f-goal-order',
1858
1853
  label: 'Sort',
1859
1854
  value: GOAL_SORTS[savedGoalSort] ? savedGoalSort : undefined,
@@ -198,6 +198,7 @@
198
198
  <script src="/builders/task-lib.js"></script>
199
199
  <script src="/builders/task-detail.js?v=2026-07-03-taskdetail"></script>
200
200
  <script src="/builders/brand-apply.js?v=2026-06-27-r75"></script>
201
- <script src="/builders/goals-page.js?v=2026-07-10-goals5"></script>
201
+ <script src="/builders/goals-lib.js?v=2026-08-21-goalslib1"></script>
202
+ <script src="/builders/goals-page.js?v=2026-08-21-goals6"></script>
202
203
  </body>
203
204
  </html>
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.279",
3
+ "version": "1.19.281",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.279",
9
+ "version": "1.19.281",
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.279",
3
+ "version": "1.19.281",
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",
@@ -2,7 +2,7 @@
2
2
  // Builder claim — atomically claim a task.
3
3
  //
4
4
  // Usage:
5
- // node scripts/gds/claim.js <task-id> [--worktree NAME]
5
+ // node scripts/gds/claim.js <task-id> [--worktree NAME] [--enforce-touches] [--join]
6
6
  //
7
7
  // On success: prints the claim id, the task title, and the touches[] so the
8
8
  // builder knows the parallel-safety boundary they're working within.
@@ -165,22 +165,31 @@ function printClaimCard(task, widgetsEnabled = true) {
165
165
  } catch (_) { /* never block a claim on the card */ }
166
166
  }
167
167
 
168
+ // task 1003138: the envelope/flat normalization from formatClaimFailure, pulled
169
+ // out so the goal-join flow reads the same { code, message, d } regardless of
170
+ // response shape. Pure + exported (tested through both consumers).
171
+ function claimErrorInfo(r) {
172
+ const errRaw = r && r.data ? r.data.error : undefined;
173
+ const isEnvelope = errRaw !== null && typeof errRaw === 'object';
174
+ return {
175
+ code: isEnvelope ? errRaw.code : errRaw,
176
+ message: isEnvelope ? errRaw.message : undefined,
177
+ // Structured fields live under .details in the envelope; fall back to the
178
+ // top-level body for the legacy flat shape (so both keep working).
179
+ d: { ...(r && r.data ? r.data : {}), ...((isEnvelope && errRaw.details) || {}) },
180
+ };
181
+ }
182
+
168
183
  // task 1476: render a failed claim as GUIDANCE, not a raw object dump. The API
169
184
  // returns the R01 error envelope (ADR 0116): { error: { code, message, details } }.
170
- // Older paths returned a flat { error: 'CODE', ...fields } shape. This normalizes
171
- // BOTH — without it `r.data.error` is now an object (never === a string), so every
172
- // branch below silently missed and the refusal printed as `[object Object]`. Pure
173
- // (no console / no process.exit) + exported so it can be unit-tested against the
174
- // real envelope shapes. Returns { lines, exit }.
185
+ // Older paths returned a flat { error: 'CODE', ...fields } shape. claimErrorInfo
186
+ // normalizes BOTH — without it `r.data.error` is now an object (never === a
187
+ // string), so every branch below silently missed and the refusal printed as
188
+ // `[object Object]`. Pure (no console / no process.exit) + exported so it can be
189
+ // unit-tested against the real envelope shapes. Returns { lines, exit }.
175
190
  function formatClaimFailure(r, taskId) {
176
191
  const lines = [];
177
- const errRaw = r && r.data ? r.data.error : undefined;
178
- const isEnvelope = errRaw !== null && typeof errRaw === 'object';
179
- const code = isEnvelope ? errRaw.code : errRaw;
180
- const message = isEnvelope ? errRaw.message : undefined;
181
- // Structured fields live under .details in the envelope; fall back to the
182
- // top-level body for the legacy flat shape (so both keep working).
183
- const d = { ...(r && r.data ? r.data : {}), ...((isEnvelope && errRaw.details) || {}) };
192
+ const { code, message, d } = claimErrorInfo(r);
184
193
 
185
194
  // #766: a generic onboarding stage gate (kept rendering cleanly, not as raw JSON).
186
195
  if (code === 'stage_required') {
@@ -263,17 +272,147 @@ function formatClaimFailure(r, taskId) {
263
272
  lines.push('Resolve it first (/blocker-solve N drives one to done; resolving auto-promotes its linked tasks) — or pick another task from /builder-start.');
264
273
  return { lines, exit: 1 };
265
274
  }
275
+ if (code === 'GOAL_MEMBERSHIP_REQUIRED') {
276
+ // task 1003138: the non-interactive rendering of the membership refusal —
277
+ // the interactive offer (runGoalJoinFlow) normally intercepts this code
278
+ // before it gets here; this branch covers a retry that re-refused, an
279
+ // unexpected body, or a decline.
280
+ lines.push(`Task #${taskId} lives in goal ${d.goal_id}${d.goal_title ? ` ("${d.goal_title}")` : ''}${d.visibility ? ` (${d.visibility})` : ''} — you are not a member.`);
281
+ lines.push(d.action === 'apply'
282
+ ? ` Apply to join (POST /api/bongos/goals/${d.goal_id}/requests) — nothing can be claimed until an owner approves. Re-run with --join to file the application from here.`
283
+ : ` Join and claim in one command: node scripts/gds/claim.js ${taskId} --join`);
284
+ return { lines, exit: 1 };
285
+ }
266
286
  // Fallback — still readable: the message + code, never a raw [object Object].
267
287
  lines.push(`Claim failed (${r ? r.status : '?'})${code ? ` [${code}]` : ''}${message ? `: ${message}` : ''}.`);
268
288
  return { lines, exit: 1 };
269
289
  }
270
290
 
291
+ // task 1003138 (owner ask, verbatim: 'claude should offer to join so they dont
292
+ // need to go through the ui') — the goal-membership refusal (403
293
+ // GOAL_MEMBERSHIP_REQUIRED, the task-1003137 gate) is resolvable in-session.
294
+ // goalMembershipOfferPlan is the PURE half: given the refusal details it names
295
+ // the offer to make. Exported for unit tests; the I/O lives in runGoalJoinFlow.
296
+ // Returns null when the body is missing the fields the flow needs (falls back
297
+ // to formatClaimFailure), else { action, headerLines, question }.
298
+ function goalMembershipOfferPlan(d, taskId) {
299
+ if (!d || d.goal_id == null || (d.action !== 'join' && d.action !== 'apply')) return null;
300
+ const g = d.goal_id;
301
+ if (d.action === 'join') {
302
+ return {
303
+ action: 'join',
304
+ headerLines: [
305
+ `✗ Task ${taskId} lives in goal ${g}`,
306
+ ...(d.goal_title ? [` "${d.goal_title}" (${d.visibility || 'public'})`] : []),
307
+ ' You are not a member.',
308
+ ],
309
+ question: `Join goal ${g} and claim? [Y/n] `,
310
+ };
311
+ }
312
+ // 'apply' — a private goal, or a public one whose scope is Archon-mediated
313
+ // (the server folds both to apply; a pending request grants nothing yet).
314
+ return {
315
+ action: 'apply',
316
+ headerLines: d.visibility === 'private'
317
+ ? [`✗ Goal ${g} is private.`]
318
+ : [
319
+ `✗ Task ${taskId} lives in goal ${g}${d.goal_title ? ` ("${d.goal_title}")` : ''} — joining needs approval (Archon-mediated scope).`,
320
+ ],
321
+ question: 'Apply to join? [Y/n] ',
322
+ };
323
+ }
324
+
325
+ // One yes/no on the real terminal (default yes — the owner-approved [Y/n]
326
+ // shape). Returns null when there is no TTY to ask — the caller then prints
327
+ // the --join re-run hint instead of hanging a non-interactive session.
328
+ async function promptYesNo(question) {
329
+ if (!process.stdin.isTTY) return null;
330
+ const readline = require('node:readline/promises');
331
+ const rl = readline.createInterface({ input: process.stdin, output: process.stderr });
332
+ try {
333
+ const a = (await rl.question(question)).trim().toLowerCase();
334
+ return a === '' || a === 'y' || a === 'yes';
335
+ } finally {
336
+ rl.close();
337
+ }
338
+ }
339
+
340
+ // The I/O half of the offer. Outcomes: 'retry' (joined — re-POST the claim),
341
+ // 'stop' (declined / filed / blocked — everything already printed, exit 1),
342
+ // 'unhandled' (fall through to formatClaimFailure). Never joins or files
343
+ // without a yes: --join is the standing yes for non-interactive sessions.
344
+ async function runGoalJoinFlow({ api, d, taskId, assumeYes }) {
345
+ const plan = goalMembershipOfferPlan(d, taskId);
346
+ if (!plan) return 'unhandled';
347
+ const goalId = d.goal_id;
348
+ for (const line of plan.headerLines) console.error(line);
349
+
350
+ const yes = assumeYes ? true : await promptYesNo(plan.question);
351
+ if (yes === null) {
352
+ // Non-interactive (an agent session): make the one-command path explicit.
353
+ console.error(plan.action === 'join'
354
+ ? ` To join goal ${goalId} and claim in one command: node scripts/gds/claim.js ${taskId} --join`
355
+ : ` To file the application from here: node scripts/gds/claim.js ${taskId} --join (nothing claimable until approved)`);
356
+ return 'stop';
357
+ }
358
+ if (!yes) {
359
+ // The owner chose the explicit-join model — declining leaves NO membership
360
+ // row and NO request.
361
+ console.error(plan.action === 'join'
362
+ ? ' Declined — nothing joined, nothing claimed.'
363
+ : ' Declined — no application filed, nothing claimed.');
364
+ return 'stop';
365
+ }
366
+
367
+ if (plan.action === 'join') {
368
+ const jr = await api.goals.postGoalsIdJoin({ id: goalId, body: {} });
369
+ if (jr.status === 200 || jr.status === 201) {
370
+ console.error(`+ joined goal ${goalId} (member)`);
371
+ return 'retry';
372
+ }
373
+ const j = claimErrorInfo(jr);
374
+ if (j.code === 'archon_approval_required') {
375
+ // The third branch, handled honestly (routes/goals.js:615): a PUBLIC goal
376
+ // with protected scope is not self-joinable — don't loop, name the path.
377
+ console.error(`✗ Goal ${goalId}'s scope is Archon-mediated — self-join is not allowed.`);
378
+ console.error(` Request to join instead: node scripts/gds/claim.js ${taskId} --join (after the server re-offers apply), or ask an Archon to add you.`);
379
+ return 'stop';
380
+ }
381
+ if (j.code === 'goal_private') {
382
+ // Visibility flipped between refusal and join — report, don't loop.
383
+ console.error(`✗ Goal ${goalId} is private now — request to join (POST /api/bongos/goals/${goalId}/requests) and wait for approval.`);
384
+ return 'stop';
385
+ }
386
+ console.error(`✗ join failed (${jr.status})${j.code ? ` [${j.code}]` : ''}${j.message ? `: ${j.message}` : ''}.`);
387
+ return 'stop';
388
+ }
389
+
390
+ // 'apply' — file the request; a pending row grants nothing, so never retry
391
+ // the claim here.
392
+ const ar = await api.goals.postGoalsIdRequests({ id: goalId, body: {} });
393
+ if (ar.status === 201) {
394
+ console.error('+ request filed — pending owner approval');
395
+ console.error(' (nothing to claim until approved)');
396
+ return 'stop';
397
+ }
398
+ const a = claimErrorInfo(ar);
399
+ if (a.code === 'request_pending') {
400
+ // The core_188 partial-unique: one pending ask per goal per builder —
401
+ // re-running reports, it does not error.
402
+ console.error('• already pending — your earlier request for this goal awaits owner approval.');
403
+ console.error(' (nothing to claim until approved)');
404
+ return 'stop';
405
+ }
406
+ console.error(`✗ application failed (${ar.status})${a.code ? ` [${a.code}]` : ''}${a.message ? `: ${a.message}` : ''}.`);
407
+ return 'stop';
408
+ }
409
+
271
410
  async function main() {
272
411
  await requireSession();
273
412
  const args = process.argv.slice(2);
274
413
  const taskId = Number(args[0]);
275
414
  if (!Number.isFinite(taskId)) {
276
- console.error('usage: node scripts/gds/claim.js <task-id> [--worktree NAME] [--enforce-touches]');
415
+ console.error('usage: node scripts/gds/claim.js <task-id> [--worktree NAME] [--enforce-touches] [--join]');
277
416
  process.exit(2);
278
417
  }
279
418
  const wIdx = args.indexOf('--worktree');
@@ -287,10 +426,15 @@ async function main() {
287
426
  // --enforce-touches so adjacency becomes a HARD refusal (it fans concurrent
288
427
  // unsupervised agents that could clobber each other on shared files).
289
428
  const enforceTouches = args.includes('--enforce-touches');
429
+ // task 1003138: --join is the standing yes to the goal-membership offer —
430
+ // join (public) or file the application (private/protected) and, for a join,
431
+ // retry the claim, all in one command. Without it an interactive session is
432
+ // asked [Y/n]; a non-TTY session is shown the --join re-run line.
433
+ const assumeYes = args.includes('--join');
290
434
  const headSha = captureHeadSha();
291
435
 
292
436
  const api = await cliClient();
293
- const r = await api.claims.postClaims({
437
+ const postClaim = () => api.claims.postClaims({
294
438
  body: {
295
439
  task_id: taskId,
296
440
  worktree_name: worktree,
@@ -298,6 +442,19 @@ async function main() {
298
442
  enforce_touches: enforceTouches,
299
443
  },
300
444
  });
445
+ let r = await postClaim();
446
+ // task 1003138: a goal-membership refusal (the task-1003137 gate) is
447
+ // resolvable in-session — offer the join/application, and on a successful
448
+ // join retry the claim ONCE (a second refusal falls through to the plain
449
+ // rendering; never loop).
450
+ if (r.status !== 201 && claimErrorInfo(r).code === 'GOAL_MEMBERSHIP_REQUIRED') {
451
+ const outcome = await runGoalJoinFlow({ api, d: claimErrorInfo(r).d, taskId, assumeYes });
452
+ if (outcome === 'retry') {
453
+ r = await postClaim();
454
+ } else if (outcome === 'stop') {
455
+ process.exit(1); // everything already printed; no claim landed
456
+ }
457
+ }
301
458
  if (r.status === 201) {
302
459
  const { claim, task } = r.data;
303
460
  console.log(`Claimed task #${task.id}: ${task.title}`);
@@ -433,4 +590,4 @@ if (require.main === module) {
433
590
  });
434
591
  }
435
592
 
436
- module.exports = { formatClaimFailure };
593
+ module.exports = { formatClaimFailure, claimErrorInfo, goalMembershipOfferPlan };
package/src/module-api.js CHANGED
@@ -985,8 +985,12 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
985
985
  // landed since 1.19.277 with no explicit bump. run 32508270995. (task 1002620)
986
986
  // 1.19.279 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
987
987
  // landed since 1.19.278 with no explicit bump. run 32521664476. (task 1002620)
988
+ // 1.19.280 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
989
+ // landed since 1.19.279 with no explicit bump. run 32523185119. (task 1002620)
990
+ // 1.19.281 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
991
+ // landed since 1.19.280 with no explicit bump. run 32525071763. (task 1002620)
988
992
  // ---------------------------------------------------------------------------
989
- const CORE_VERSION = '1.19.279'; // CI auto-patch carrier (ADR 0161); see changelog above
993
+ const CORE_VERSION = '1.19.281'; // CI auto-patch carrier (ADR 0161); see changelog above
990
994
 
991
995
  // A namespaced logger so a module's log lines are attributable + consistent.
992
996
  // Usage: const log = api.logger('dev-box'); log.info('mounted');