@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.
- package/.bongos-core.json +28 -18
- package/.claude/skills/builder-claim/SKILL.md +5 -0
- package/docs/copy-inventory.md +91 -90
- package/docs/copy-registry.json +114 -105
- package/modules/hall-ui/public/goals-lib.js +99 -0
- package/modules/hall-ui/public/goals-page.js +67 -72
- package/modules/hall-ui/public/goals.html +2 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/claim.js +173 -16
- package/src/module-api.js +5 -1
- package/tests/claim_error_surface.mjs +55 -2
- package/tests/goal_criterion_confirm_ui.mjs +4 -0
- package/tests/goals_lib.mjs +70 -0
|
@@ -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
|
-
|
|
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-
|
|
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.
|
|
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.
|
|
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.
|
|
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",
|
package/scripts/gds/claim.js
CHANGED
|
@@ -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.
|
|
171
|
-
// BOTH — without it `r.data.error` is now an object (never === a
|
|
172
|
-
// branch below silently missed and the refusal printed as
|
|
173
|
-
// (no console / no process.exit) + exported so it can be
|
|
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
|
|
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
|
|
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.
|
|
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');
|