@bongos/core 1.19.332 → 1.19.334

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/.bongos-core.json +173 -43
  2. package/docs/adr/0187-oauth-handshake-cookie-lifetime.md +84 -0
  3. package/docs/adr/README.md +1 -0
  4. package/docs/api/openapi.json +2 -2
  5. package/docs/api-reference.md +1 -1
  6. package/docs/copy-inventory.md +202 -202
  7. package/docs/copy-registry.json +384 -384
  8. package/modules/grading/grader-config.js +98 -0
  9. package/modules/grading/grader-model.js +150 -0
  10. package/modules/grading/grader-prompt.js +139 -0
  11. package/modules/grading/grader-score.js +706 -0
  12. package/modules/grading/grader-subagent.js +328 -0
  13. package/modules/grading/grader.js +5 -1287
  14. package/modules/hall-ui/public/builders.js +19 -1182
  15. package/modules/hall-ui/public/goals-page.js +11 -997
  16. package/modules/hall-ui/public/goals-render.js +1032 -0
  17. package/modules/hall-ui/public/goals.html +4 -0
  18. package/modules/hall-ui/public/hall-render.js +1211 -0
  19. package/modules/hall-ui/public/index.html +6 -0
  20. package/modules/lifecycle/routes/goal-route-authz.js +400 -0
  21. package/modules/lifecycle/routes/goals.js +1 -367
  22. package/modules/lifecycle/routes/task-route-helpers.js +219 -0
  23. package/modules/lifecycle/routes/task-write-routes.js +542 -0
  24. package/modules/lifecycle/routes/tasks.js +3 -696
  25. package/package-lock.json +2 -2
  26. package/package.json +1 -1
  27. package/scripts/gds/box-infra.js +280 -0
  28. package/scripts/gds/box.js +1 -245
  29. package/scripts/gds/fitness-checks-identity.js +320 -0
  30. package/scripts/gds/fitness-checks-vocabulary.js +230 -0
  31. package/scripts/gds/fitness-checks-write-validation.js +144 -0
  32. package/scripts/gds/fitness-lib.js +82 -0
  33. package/scripts/gds/fitness.js +55 -668
  34. package/scripts/gds/provision-config.js +204 -0
  35. package/scripts/gds/provision-net.js +221 -0
  36. package/scripts/gds/provision-repo.js +849 -0
  37. package/scripts/gds/provision-units.js +262 -0
  38. package/scripts/gds/provision.js +4 -1395
  39. package/src/bongos/auth-admission.js +264 -0
  40. package/src/bongos/auth-config.js +151 -0
  41. package/src/bongos/auth-discord.js +198 -0
  42. package/src/bongos/auth-github.js +336 -0
  43. package/src/bongos/auth-idp.js +330 -0
  44. package/src/bongos/auth.js +5 -1100
  45. package/src/bongos/routes/auth.js +54 -10
  46. package/src/module-api.js +5 -1
  47. package/tests/auth_admission_gate.mjs +3 -1
  48. package/tests/auth_web_recovery.mjs +63 -0
  49. package/tests/est_advisory.mjs +3 -1
  50. package/tests/goal_advisory.mjs +3 -2
  51. package/tests/goal_criterion_confirm_ui.mjs +6 -0
  52. package/tests/goal_subtitle.mjs +5 -2
  53. package/tests/goals_card_view.mjs +10 -3
  54. package/tests/grader_generated_provenance.mjs +3 -1
  55. package/tests/hall_palette.mjs +3 -1
  56. package/tests/helpers.mjs +58 -25
  57. package/tests/lib_sh_resolution.mjs +3 -1
  58. package/tests/membership_kind_lockstep.mjs +3 -1
  59. package/tests/migration_allocation.mjs +5 -2
  60. package/tests/patch_gate_ordering.mjs +3 -1
  61. package/tests/rank_tier_single_source.mjs +16 -3
  62. package/tests/reward_gate.mjs +3 -2
@@ -0,0 +1,1032 @@
1
+ // goals-render.js — the Goals page's RENDERERS, exposed as window.OTBGoalsRender.
2
+ //
3
+ // The second half of the carve goals-lib.js began (task 1003135): that one took
4
+ // the page's PURE helpers, which was not enough to get goals-page.js under the
5
+ // 1,500-line ratchet. Task 1003206 (goal 1000079) takes the view painters too —
6
+ // the goal list and cards, the detail view, the todo/work boards, conflicts, the
7
+ // completion panel, members and invites.
8
+ //
9
+ // A FACTORY rather than a flat namespace, unlike goals-lib.js, for one reason:
10
+ // these read the page's live `state` object and its work-tab controller, which is
11
+ // reassigned after boot. `state` is a stable reference so it is destructured, but
12
+ // `workTabs` is read through ctx so a renderer always sees the current one.
13
+ //
14
+ // Load order (goals.html): hall-kit → task-lib → goals-lib → goals-render →
15
+ // goals-page. Every window.* toolkit below is re-destructured here from the same
16
+ // globals goals-page.js reads, so the moved call sites are byte-identical.
17
+ (function () {
18
+ 'use strict';
19
+
20
+ window.OTBGoalsRender = function createGoalsRender(ctx) {
21
+ const { API, curLabel, getJSON, patchJSON, postJSON, sameId, selfId, state } = ctx;
22
+ const { escapeHtml, $, fmtNum, fmtDate, toast, rankLabel } = window.OTB;
23
+ const {
24
+ priorityRank, touchesConflict, rankTagHtml, deHashRefs,
25
+ creditsPerMinute, buildPromptForTask, friendlyBatchReason,
26
+ } = window.OTBTaskLib;
27
+ const { makePager, paginate, wirePager, pagerBarHtml, makeTabs, makeFilterBar,
28
+ emptyStateHtml, skeletonRows } = window.OTBKit;
29
+ const { DONE_STATUSES, CUT_STATUSES, isTodoStatus, ring, memberName,
30
+ readinessOf, EMPTY_ROLLUP, goalRollLine, matchesTodoSearch,
31
+ msOf, taskActivityMs, subtitleAttr, applyGoalProse } = window.OTBGoalsLib;
32
+
33
+ const CONFLICT_CAP = 3;
34
+ // task 1828 — the band's Completion / Members / Conflicts columns each cap
35
+ // their list and offer a "View all →" into a dedicated full view (these grow:
36
+ // more members, more criteria). Conflicts uses CONFLICT_CAP above.
37
+ const MEMBER_CAP = 5;
38
+ const CRITERIA_CAP = 4;
39
+ // task 1932/1956 → kit (task 2171): the goal-card grid + the goal-detail Work
40
+ // tables (To do / Completed) all paginate through the shared OTBKit pager.
41
+ // localStorage keys are unchanged so builders' per-page prefs survive; the bar
42
+ // markup is stamped into the #…-pager-mount divs at boot via pagerBarHtml.
43
+ const pagers = {
44
+ list: makePager('goals', 'otb.goals.perPage'),
45
+ todo: makePager('goal-todo', 'otb.goals.todoPerPage'),
46
+ done: makePager('goal-done', 'otb.goals.donePerPage'),
47
+ };
48
+ const taskSettled = (t) => window.OTBBranding?.taskSettled?.(t) ?? (!!t && (t.status === 'confirmed' || t.status === 'shipped'));
49
+ const creditVal = (t) => Number(window.OTBBranding?.taskCreditValue?.(t) ?? t?.credits_reward) || 0;
50
+ function statusBadge(status) {
51
+ const cls = status === 'achieved' ? 'goal-badge--achieved'
52
+ : (status === 'archived' ? 'goal-badge--archived' : 'goal-badge--open');
53
+ return `<span class="goal-badge ${cls}">${escapeHtml(status)}</span>`;
54
+ }
55
+ const rollupOf = (goalId) => state.rollups.get(String(goalId)) || EMPTY_ROLLUP;
56
+ const goalCreatedMs = (g) => msOf(g.created_at);
57
+ const goalActivityMs = (g) => state.activity.get(String(g.id)) || msOf(g.updated_at);
58
+ const GOAL_SORTS = {
59
+ priority: { label: 'priority (version order)', cmp: null, stamp: null },
60
+ newest: { label: 'newest first', cmp: (a, b) => goalCreatedMs(b) - goalCreatedMs(a), stamp: (g) => ({ label: 'created', ms: goalCreatedMs(g) }) },
61
+ oldest: { label: 'oldest first', cmp: (a, b) => goalCreatedMs(a) - goalCreatedMs(b), stamp: (g) => ({ label: 'created', ms: goalCreatedMs(g) }) },
62
+ active: { label: 'recently active', cmp: (a, b) => goalActivityMs(b) - goalActivityMs(a), stamp: (g) => ({ label: 'last work', ms: goalActivityMs(g) }) },
63
+ };
64
+ const currentGoalSort = () => GOAL_SORTS[$('#f-goal-order')?.value] || GOAL_SORTS.priority;
65
+ function sortGoals(rows) {
66
+ const { cmp } = currentGoalSort();
67
+ return cmp ? rows.slice().sort(cmp) : rows;
68
+ }
69
+ const isArchon = () => (state.me?.builder?.rank || '').toLowerCase() === 'archon';
70
+ const RANK_ORDER = { archon: 4, metic: 3, thetes: 2, xenos: 1 };
71
+ const canConfirmCriteria = () =>
72
+ (RANK_ORDER[(state.me?.builder?.rank || '').toLowerCase()] ?? 0) >= RANK_ORDER.metic;
73
+ function filteredGoals() {
74
+ const ver = $('#f-goal-version')?.value || '';
75
+ const status = $('#f-goal-status')?.value || '';
76
+ const mineOnly = $('#f-goal-mine')?.value === 'mine';
77
+ // task 1003135 — free-text search (goal title OR id; '#N', 'N' and a bare
78
+ // number all match — the id is already rendered on the row, task 1002163)
79
+ // and the Owner dropdown ('none' = created_by NULL, system-attributed).
80
+ // Reads the DOM ids directly, matching this function's existing style.
81
+ const q = ($('#f-goals-search')?.value || '').trim().toLowerCase();
82
+ const needle = q.replace(/^#/, '');
83
+ const owner = $('#f-goal-owner')?.value || '';
84
+ const matchesSearch = (g) => !needle
85
+ || String(g.id).includes(needle)
86
+ || (g.title || '').toLowerCase().includes(needle);
87
+ const matchesOwner = (g) => !owner
88
+ || (owner === 'none' ? g.created_by == null : String(g.created_by) === owner);
89
+ const myId = selfId();
90
+ const iAmMember = (g) => {
91
+ const d = state.details.get(String(g.id));
92
+ return d ? (d.members || []).some((m) => sameId(m.builder_id, myId)) : false;
93
+ };
94
+ return state.goals.filter((g) =>
95
+ (!ver || g.version_id === ver) &&
96
+ (!status || g.status === status) &&
97
+ matchesSearch(g) &&
98
+ matchesOwner(g) &&
99
+ (!mineOnly || iAmMember(g)));
100
+ }
101
+ function goalStampHtml(g) {
102
+ const { stamp } = currentGoalSort();
103
+ if (!stamp) return '';
104
+ const { label, ms } = stamp(g);
105
+ return ` · ${escapeHtml(label)} ${escapeHtml(ms ? fmtDate(new Date(ms).toISOString()) : '—')}`;
106
+ }
107
+ function goalRenderFacts(g, ringSize) {
108
+ const d = state.details.get(String(g.id));
109
+ const crit = d ? (d.criteria || []) : [];
110
+ const sat = crit.filter((c) => c.satisfied).length;
111
+ const r = rollupOf(g.id);
112
+ const achieved = g.status === 'achieved';
113
+ const ringHtml = crit.length ? ring(sat, crit.length, achieved, ringSize)
114
+ : `<span class="gr-crit-none" aria-hidden="true">—</span>`;
115
+ const goalHref = `#/goal/${escapeHtml(String(g.id))}`;
116
+ let right;
117
+ if (achieved) {
118
+ right = `<span class="gr-met">✓ achieved</span>`;
119
+ } else if (crit.length && sat >= crit.length) {
120
+ right = `<span class="gr-met">Criteria met — awaiting review</span>`;
121
+ } else if (r.nextReady) {
122
+ right = `<div class="gr-next"><span class="gr-next__t">${escapeHtml(r.nextReady.title || 'Next task')}</span>`
123
+ + `<a class="gr-claim" href="#/task/${escapeHtml(String(r.nextReady.id))}">Claim next</a></div>`;
124
+ } else if (r.blocked > 0) {
125
+ right = `<a class="gr-drill" href="${goalHref}">See what's blocking <svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="1.9" aria-hidden="true"><path d="M5 12h14M13 6l6 6-6 6"/></svg></a>`;
126
+ } else {
127
+ right = `<span class="gr-quiet">No tasks yet</span>`;
128
+ }
129
+ const metaHtml = `#${escapeHtml(String(g.id))} · ${escapeHtml(g.version_id || '')}${(achieved || g.status === 'open') ? '' : ' · ' + escapeHtml(g.status)}${goalStampHtml(g)}`;
130
+ return { r, achieved, ringHtml, goalHref, right, metaHtml };
131
+ }
132
+ function goalRowHtml(g) {
133
+ const { r, achieved, ringHtml, goalHref, right, metaHtml } = goalRenderFacts(g, 44);
134
+ // NOTE: a rollup row nests real links (title → goal, Claim → task overlay,
135
+ // drill → goal), so the row itself is a <div>, not an <a> — nested anchors
136
+ // are invalid. Only the explicit links navigate.
137
+ return `<div class="gr-row" data-goal-id="${escapeHtml(String(g.id))}">
138
+ <div class="gr-ringcol">${ringHtml}</div>
139
+ <div class="gr-main">
140
+ <a class="gr-title" href="${goalHref}"${subtitleAttr(g)}>${escapeHtml(g.title || 'Untitled goal')}</a>
141
+ <div class="gr-meta">${metaHtml}</div>
142
+ ${achieved ? '' : goalRollLine(r)}
143
+ </div>
144
+ <div class="gr-right">${right}</div>
145
+ </div>`;
146
+ }
147
+ function goalCardHtml(g) {
148
+ const { r, achieved, ringHtml, goalHref, right, metaHtml } = goalRenderFacts(g, 36);
149
+ return `<div class="goal-card" data-goal-id="${escapeHtml(String(g.id))}">
150
+ <div class="goal-card__head">
151
+ <div class="gr-ringcol">${ringHtml}</div>
152
+ <a class="goal-card__title gr-title" href="${goalHref}"${subtitleAttr(g)}>${escapeHtml(g.title || 'Untitled goal')}</a>
153
+ </div>
154
+ <div class="goal-card__meta">${metaHtml}</div>
155
+ ${achieved ? '' : goalRollLine(r)}
156
+ <div class="goal-card__foot">${right}</div>
157
+ </div>`;
158
+ }
159
+ function renderList() {
160
+ const grid = $('#goal-grid');
161
+ if (!grid) return;
162
+ const rows = filteredGoals();
163
+ const count = $('#goals-count');
164
+ if (count) count.textContent = `${rows.length} goal${rows.length === 1 ? '' : 's'}`;
165
+ const pagerMount = $('#goals-pager-mount');
166
+ if (pagerMount) pagerMount.hidden = true;
167
+
168
+ if (!rows.length) {
169
+ grid.innerHTML = emptyStateHtml('No goals match these filters.');
170
+ return;
171
+ }
172
+
173
+ const isBlocked = (g) => {
174
+ const r = rollupOf(g.id);
175
+ return g.status === 'open' && r.ready === 0 && r.active === 0 && r.blocked > 0;
176
+ };
177
+ // task 1002965 — the Sort choice orders goals WITHIN each group; the
178
+ // Active / Blocked / Achieved / Archived grouping (task 1002439's momentum
179
+ // layout) is what the page is for and is never re-ordered away.
180
+ const achieved = sortGoals(rows.filter((g) => g.status === 'achieved'));
181
+ const archived = sortGoals(rows.filter((g) => g.status === 'archived'));
182
+ const openRows = rows.filter((g) => g.status !== 'achieved' && g.status !== 'archived');
183
+ const blocked = sortGoals(openRows.filter(isBlocked));
184
+ const active = sortGoals(openRows.filter((g) => !isBlocked(g)));
185
+
186
+ let R = 0, A = 0, B = 0;
187
+ openRows.forEach((g) => { const r = rollupOf(g.id); R += r.ready; A += r.active; B += r.blocked; });
188
+ const summary = `<div class="gr-summary">
189
+ <span class="gr-sm"><span class="gr-sm__dot" style="background:var(--ok)"></span><b>${R}</b> ready to claim</span>
190
+ <span class="gr-sm"><span class="gr-sm__dot" style="background:var(--info)"></span><b>${A}</b> in progress</span>
191
+ <span class="gr-sm"><span class="gr-sm__dot" style="background:var(--terracotta)"></span><b>${B}</b> blocked</span>
192
+ </div>`;
193
+
194
+ const sec = (label, n) => `<div class="gr-sec"><b>${label}</b><span class="gr-sec__n">${n}</span></div>`;
195
+ // task 1003136 — the list|cards toggle swaps how the open groups render;
196
+ // grouping, filters, and sort run above the fork so both views share them.
197
+ // Achieved/archived pills stay pills (already the compact form). The inner
198
+ // div is class-only .goal-grid on purpose: task 1002439's block override
199
+ // targets #goal-grid.goal-grid, so the original card-grid CSS applies here.
200
+ const cardsView = ($('#f-goal-view')?.value || 'list') === 'cards';
201
+ const listOf = cardsView
202
+ ? (arr) => `<div class="goal-grid">${arr.map(goalCardHtml).join('')}</div>`
203
+ : (arr) => `<div class="gr-list">${arr.map(goalRowHtml).join('')}</div>`;
204
+ // task 1002965 — an achieved/archived goal is a compact pill, so its sort
205
+ // date rides as its own muted span (the row's `gr-meta` equivalent). The
206
+ // pills row already wraps, so the extra text can't widen the page.
207
+ const pillWhen = (g) => {
208
+ const { stamp } = currentGoalSort();
209
+ if (!stamp) return '';
210
+ const { label, ms } = stamp(g);
211
+ return `<span class="gr-pill__when">${escapeHtml(label)} ${escapeHtml(ms ? fmtDate(new Date(ms).toISOString()) : '—')}</span>`;
212
+ };
213
+ const pill = (g) => `<a class="gr-pill" href="#/goal/${escapeHtml(String(g.id))}"><span class="gr-pill__ok">✓</span><b>${escapeHtml(g.title || 'Untitled')}</b><span class="gr-pill__id">#${escapeHtml(String(g.id))}</span>${pillWhen(g)}</a>`;
214
+
215
+ grid.innerHTML = summary
216
+ + (active.length ? sec('Active', active.length) + listOf(active) : '')
217
+ + (blocked.length ? sec('Blocked', blocked.length) + listOf(blocked) : '')
218
+ + (achieved.length ? sec('Achieved', achieved.length) + `<div class="gr-pills">${achieved.map(pill).join('')}</div>` : '')
219
+ + (archived.length ? sec('Archived', archived.length) + `<div class="gr-pills gr-pills--muted">${archived.map(pill).join('')}</div>` : '');
220
+ }
221
+ function todoSearchQuery() {
222
+ const el = $('#f-goal-search');
223
+ return el ? el.value.trim().toLowerCase() : '';
224
+ }
225
+ function goalTaskCardHtml(t) {
226
+ const id = String(t.id);
227
+ const cpm = creditsPerMinute(t);
228
+ const cpmText = Number.isFinite(cpm) ? `${cpm.toFixed(2)} ${curLabel()}/min` : `— ${curLabel()}/min`;
229
+ const minutesText = t.est_minutes ? `${t.est_minutes}m` : '—';
230
+ const discipline = t.discipline || 'unclassified';
231
+ const disciplineTag = discipline !== 'unclassified' ? `<span class="task__tag">${escapeHtml(discipline)}</span>` : '';
232
+ const newcomerTag = t.newcomer_friendly ? '<span class="task__tag task__tag--newcomer">newcomer</span>' : '';
233
+ const rankTag = rankTagHtml(t.requires_rank);
234
+ const touches = Array.isArray(t.touches) ? t.touches : [];
235
+ const touchesChip = touches.length
236
+ ? `<span class="task__touches" title="${escapeHtml(touches.join('\n'))}">✎ ${touches.length} file${touches.length === 1 ? '' : 's'}</span>`
237
+ : '';
238
+ // task 1002955: same age chip the board renders — reviewing a goal for stale
239
+ // work is the case that needed it most.
240
+ const ageChip = window.OTBKit.ageChipHtml(t.created_at);
241
+ const claim = state.claimsByTask.get(id);
242
+ const isReady = t.status === 'ready' && !!state.me;
243
+ const mineActive = t.status === 'active' && claim && sameId(claim.builder_id, selfId());
244
+ const selected = goalSel.claim.has(id);
245
+ // Lead: a select checkbox on ready cards (drives bulk claim); otherwise a
246
+ // status tag so active/backlog cards read at a glance.
247
+ const lead = isReady
248
+ ? `<input type="checkbox" class="task__select" data-task-id="${escapeHtml(id)}" aria-label="Select task ${escapeHtml(id)} for bulk claim"${selected ? ' checked' : ''}>`
249
+ : `<span class="task__tag" title="Task status">${escapeHtml(t.status || '')}</span>`;
250
+ // Actions: ready → Claim + copy; mine-active → Release; other's active →
251
+ // "claimed · @who"; anything else → nothing (open via the ⤢ button / card).
252
+ let actions = '';
253
+ if (isReady) {
254
+ actions = `<button class="task__claim" type="button" data-claim-task="${escapeHtml(id)}" aria-label="Claim task ${escapeHtml(id)}">Claim</button>`
255
+ + `<button class="task__copy" type="button" data-copy-task="${escapeHtml(id)}" title="Copy the task prompt to your clipboard" aria-label="Copy prompt for task ${escapeHtml(id)}"><span class="task__copy-icon" aria-hidden="true">📋</span></button>`;
256
+ } else if (mineActive) {
257
+ actions = `<button class="wip__release" type="button" data-release-claim="${escapeHtml(String(claim.claim_id))}" data-release-task="${escapeHtml(id)}" aria-label="Release task ${escapeHtml(id)}">Release</button>`;
258
+ } else if (t.status === 'active' && claim) {
259
+ const who = (claim.login || claim.name) ? ` · @${escapeHtml(claim.login || claim.name)}` : '';
260
+ actions = `<span class="ledger__claimed" title="Claimed by another builder">claimed${who}</span>`;
261
+ }
262
+ return `
263
+ <div class="task${selected ? ' task--selected' : ''}${isReady ? '' : ' task--readonly'}" data-task-id="${escapeHtml(id)}">
264
+ <div class="task__id">
265
+ ${lead}
266
+ <span>#${escapeHtml(id)}</span>
267
+ <button class="task__open" type="button" data-task-id="${escapeHtml(id)}" title="Open task details" aria-label="Open details for task ${escapeHtml(id)}">⤢</button>
268
+ </div>
269
+ <div class="task__body">
270
+ <p class="task__title">${escapeHtml(t.title || '')}</p>
271
+ <div class="task__meta">
272
+ <span class="task__tag">${escapeHtml(t.version_id || '?')}</span>
273
+ <span class="task__tag">${escapeHtml(t.kind || 'unclassified')}</span>
274
+ ${disciplineTag}
275
+ ${newcomerTag}
276
+ ${rankTag}
277
+ ${t.priority ? `<span class="task__tag">${escapeHtml(t.priority)}</span>` : ''}
278
+ <span>${minutesText}</span>
279
+ <span>${cpmText}</span>
280
+ ${touchesChip}
281
+ ${ageChip}
282
+ </div>
283
+ </div>
284
+ <div class="task__credits">
285
+ <span class="task__credits-num">${fmtNum(window.OTBBranding?.taskCreditValue?.(t) ?? t.credits_reward)}</span>
286
+ <span class="task__credits-label" title="${window.OTBBranding?.rewardModeNote?.() ?? ''}">${window.OTBBranding?.taskCreditLabel?.(t) ?? (curLabel() + ' Est.')}</span>
287
+ </div>
288
+ <div class="task__actions">${actions}</div>
289
+ </div>`;
290
+ }
291
+ const goalSel = { claim: new Set() };
292
+ function goalSelTotal() { return goalSel.claim.size; }
293
+ function pruneGoalSelection() {
294
+ const readyIds = new Set(state.work.todo.filter((t) => t.status === 'ready').map((t) => String(t.id)));
295
+ for (const id of Array.from(goalSel.claim)) if (!readyIds.has(id)) goalSel.claim.delete(id);
296
+ }
297
+ function renderGoalBulkToolbar() {
298
+ const bar = $('#goal-bulk-toolbar');
299
+ if (!bar) return;
300
+ if (goalSelTotal() === 0) { bar.hidden = true; return; }
301
+ bar.hidden = false;
302
+ const countEl = $('#goal-bulk-count');
303
+ if (countEl) countEl.textContent = `${goalSel.claim.size} to claim`;
304
+ const claimBtn = $('#goal-bulk-claim-btn');
305
+ if (claimBtn) {
306
+ claimBtn.disabled = goalSel.claim.size === 0;
307
+ claimBtn.textContent = goalSel.claim.size > 1 ? `Bulk claim (${goalSel.claim.size})` : 'Bulk claim';
308
+ }
309
+ }
310
+ function taskRowsDone(tasks) {
311
+ return tasks.map((t) => `
312
+ <tr class="ledger__row--task" data-task-id="${escapeHtml(String(t.id))}" style="cursor:pointer" title="Open task #${escapeHtml(String(t.id))}">
313
+ <td class="ledger__num">#${escapeHtml(String(t.id))}</td>
314
+ <td>${escapeHtml(t.title || '')}</td>
315
+ <td>${escapeHtml(t.kind || '—')}</td>
316
+ <td class="ledger__drachmae">${fmtNum(creditVal(t))}${taskSettled(t) ? '' : ' <span class="ledger__est" title="Estimate — credits settle when the task is confirmed">est.</span>'}</td>
317
+ <td class="ledger__date">${fmtDate(t.shipped_at)}</td>
318
+ </tr>`).join('');
319
+ }
320
+ function pathOverlap(a, b) {
321
+ const norm = (p) => String(p).replace(/\/?\*+$/, '').replace(/\/+$/, '');
322
+ const hits = [];
323
+ for (const pa of a) {
324
+ for (const pb of b) {
325
+ const na = norm(pa); const nb = norm(pb);
326
+ if (!na || !nb) continue;
327
+ if (na === nb || na.startsWith(nb + '/') || nb.startsWith(na + '/')) {
328
+ hits.push(na.length >= nb.length ? na : nb);
329
+ }
330
+ }
331
+ }
332
+ return [...new Set(hits)];
333
+ }
334
+ function computeConflicts(tasks) {
335
+ const open = tasks.filter((t) => isTodoStatus(t.status) && Array.isArray(t.touches) && t.touches.length);
336
+ const out = [];
337
+ for (let i = 0; i < open.length; i++) {
338
+ for (let j = i + 1; j < open.length; j++) {
339
+ const paths = pathOverlap(open[i].touches, open[j].touches);
340
+ if (paths.length) out.push({ a: open[i], b: open[j], paths });
341
+ }
342
+ }
343
+ return out;
344
+ }
345
+ function setCount(sel, n) {
346
+ const el = $(sel);
347
+ if (!el) return;
348
+ el.hidden = false;
349
+ el.textContent = String(n);
350
+ }
351
+ const WORK_SORT_HEADERS = {
352
+ todo: { kind: 'goal-todo-sort-kind', status: 'goal-todo-sort-status', credits: 'goal-todo-sort-credits' },
353
+ done: { kind: 'goal-done-sort-kind', credits: 'goal-done-sort-credits', shipped: 'goal-done-sort-shipped' },
354
+ };
355
+ function sortWorkRows(rows, sort) {
356
+ if (!sort || !sort.by) return rows.slice();
357
+ const dir = sort.dir === 'asc' ? 1 : -1;
358
+ const val = (t) => {
359
+ switch (sort.by) {
360
+ case 'kind': return String(t.kind || '').toLowerCase();
361
+ case 'status': return String(t.status || '').toLowerCase();
362
+ case 'credits': return creditVal(t);
363
+ case 'shipped': return new Date(t.shipped_at || 0).getTime();
364
+ default: return 0;
365
+ }
366
+ };
367
+ return rows
368
+ .map((row, idx) => ({ row, idx }))
369
+ .sort((a, b) => {
370
+ const av = val(a.row); const bv = val(b.row);
371
+ const diff = (typeof av === 'number')
372
+ ? (av - bv) * dir
373
+ : String(av).localeCompare(String(bv)) * dir;
374
+ return diff !== 0 ? diff : a.idx - b.idx; // stable tiebreak
375
+ })
376
+ .map(({ row }) => row);
377
+ }
378
+ function reflectSortHeaders(table) {
379
+ const sort = state.workSort[table];
380
+ for (const [key, id] of Object.entries(WORK_SORT_HEADERS[table])) {
381
+ const th = document.getElementById(id);
382
+ if (!th) continue;
383
+ th.setAttribute('aria-sort',
384
+ sort.by === key ? (sort.dir === 'asc' ? 'ascending' : 'descending') : 'none');
385
+ }
386
+ }
387
+ function renderWork() {
388
+ renderTodoCards();
389
+ // ---- Completed → unchanged ledger table (the board's Completed tab is also
390
+ // a plain list, so the goal keeps its sortable table here). ----
391
+ const doneBody = document.querySelector('#goal-done tbody');
392
+ if (doneBody) {
393
+ const done = sortWorkRows(state.work.done, state.workSort.done);
394
+ const doneSlice = paginate(done, pagers.done);
395
+ doneBody.innerHTML = done.length
396
+ ? taskRowsDone(doneSlice)
397
+ : `<tr><td colspan="5" class="ledger__empty">${emptyStateHtml('Nothing completed yet.')}</td></tr>`;
398
+ }
399
+ ctx.workTabs?.setCount('todo', state.work.todo.length);
400
+ ctx.workTabs?.setCount('done', state.work.done.length);
401
+ reflectSortHeaders('done');
402
+ renderGoalBulkToolbar();
403
+ }
404
+ function renderTodoCards() {
405
+ const listEl = $('#goal-todo-list');
406
+ if (!listEl) return;
407
+ const sQuery = todoSearchQuery();
408
+ const kFilter = $('#f-goal-kind')?.value || '';
409
+ const dFilter = $('#f-goal-discipline')?.value || '';
410
+ const nFilter = $('#f-goal-newcomer')?.value || '';
411
+ const sortBy = $('#f-goal-sort')?.value || 'cpm';
412
+ let list = state.work.todo.filter((t) => {
413
+ if (!matchesTodoSearch(t, sQuery)) return false;
414
+ if (kFilter && (t.kind || 'unclassified') !== kFilter) return false;
415
+ if (dFilter && (t.discipline || 'unclassified') !== dFilter) return false;
416
+ if (nFilter === 'yes' && !t.newcomer_friendly) return false;
417
+ return true;
418
+ });
419
+ // Run-mode filter: parallel-safe (touches[] disjoint from the rest of the
420
+ // visible set) vs. file-conflicting. O(n²) over touches — fine at goal scale.
421
+ const fitFilter = $('#f-goal-fit')?.value || '';
422
+ if (fitFilter === 'parallel' || fitFilter === 'conflict') {
423
+ const base = list;
424
+ list = base.filter((t) => {
425
+ const conflicts = base.some((o) => o !== t && touchesConflict(t.touches, o.touches));
426
+ return fitFilter === 'parallel' ? !conflicts : conflicts;
427
+ });
428
+ }
429
+ if (sortBy === 'cpm') list = list.slice().sort((a, b) => creditsPerMinute(b) - creditsPerMinute(a));
430
+ else if (sortBy === 'credits') list = list.slice().sort((a, b) => creditVal(b) - creditVal(a));
431
+ else if (sortBy === 'minutes') list = list.slice().sort((a, b) => (Number(a.est_minutes) || Infinity) - (Number(b.est_minutes) || Infinity));
432
+ else if (sortBy === 'priority') list = list.slice().sort((a, b) => priorityRank(a.priority) - priorityRank(b.priority));
433
+ else if (sortBy === 'id') list = list.slice().sort((a, b) => Number(a.id) - Number(b.id));
434
+ else if (sortBy === 'status') list = list.slice().sort((a, b) => String(a.status).localeCompare(String(b.status)));
435
+
436
+ const countEl = $('#goal-todo-count');
437
+ if (countEl) countEl.textContent = state.work.todo.length ? `${list.length} of ${state.work.todo.length} shown` : '';
438
+ if (!list.length) {
439
+ listEl.innerHTML = emptyStateHtml(state.work.todo.length
440
+ ? 'No tasks match these filters. Try widening them.'
441
+ : `Nothing queued under this goal. Members can author bounded tasks here${state.me ? '' : ' once signed in'}.`);
442
+ return;
443
+ }
444
+ const shown = paginate(list, pagers.todo);
445
+ listEl.innerHTML = shown.map(goalTaskCardHtml).join('');
446
+ }
447
+ function errCode(data) {
448
+ const e = data && data.error;
449
+ return (e && typeof e === 'object') ? e.code : e;
450
+ }
451
+ function errDetails(data) {
452
+ const e = data && data.error;
453
+ return (e && typeof e === 'object' && e.details) ? e.details : (data || {});
454
+ }
455
+ function friendlyClaimError(status, data) {
456
+ const det = errDetails(data);
457
+ switch (errCode(data)) {
458
+ case 'ALREADY_CLAIMED': return 'Someone else claimed that task first. Reloading.';
459
+ case 'TASK_NOT_READY': return 'That task is no longer ready to be claimed.';
460
+ case 'TOUCHES_CONFLICT': {
461
+ const titles = Array.isArray(det.conflicts) ? det.conflicts.map((c) => c.title).filter(Boolean) : [];
462
+ return titles.length ? `That task overlaps an active claim: ${titles.join('; ')}.` : 'That task overlaps another active claim.';
463
+ }
464
+ case 'DEPS_NOT_SHIPPED': return 'That task waits on unshipped dependencies.';
465
+ case 'rank_too_low_for_task':
466
+ case 'XENOS_RESTRICTED':
467
+ case 'INSUFFICIENT_RANK': return 'Your rank is not yet high enough for that task.';
468
+ case 'newcomer_must_ship_first': return det.what_to_do || 'Ship your current claim before opening another.';
469
+ case 'NOT_YOUR_CLAIM': return 'That claim is not yours to release.';
470
+ case 'ALREADY_RESOLVED': return 'That claim was already resolved. Reloading.';
471
+ case 'BUILDER_INACTIVE': return 'Your account is inactive. Contact an Archon.';
472
+ default: return `The board could not do that (${status}). Try again.`;
473
+ }
474
+ }
475
+ function conflictHtml(c) {
476
+ const sev = c.severity
477
+ ? `<span class="conflict__sev conflict__sev--${escapeHtml(c.severity)}">${escapeHtml(c.severity)}</span>`
478
+ : '';
479
+ const cross = c.crossGoalTitle ? ` <small>(with ${escapeHtml(c.crossGoalTitle)})</small>` : '';
480
+ return `<div class="conflict">
481
+ <div class="conflict__pair">#${escapeHtml(String(c.a))} ${escapeHtml(c.titleA || '')} ↔ #${escapeHtml(String(c.b))} ${escapeHtml(c.titleB || '')}${cross}${sev}</div>
482
+ <div class="conflict__paths">${(c.paths || []).map(escapeHtml).join(' · ')}</div>
483
+ </div>`;
484
+ }
485
+ function fillConflicts(container, pairs) {
486
+ if (!container) return;
487
+ if (pairs == null) {
488
+ container.innerHTML = emptyStateHtml('Conflicts could not be computed.');
489
+ return;
490
+ }
491
+ const note = state.conflictSource === 'client'
492
+ ? '<p class="conflict-note">Path-based estimate from declared files. Glob-aware detection with claim collisions — and lead resolution actions (sequencing, rescoping) — light up when the server-side detector lands (tasks 1735/1736).</p>'
493
+ : '';
494
+ container.innerHTML = (pairs.length
495
+ ? pairs.map(conflictHtml).join('')
496
+ : emptyStateHtml('No conflicts detected among this goal’s open tasks.')) + note;
497
+ }
498
+ const currentDetail = () => (state.current == null ? null : state.details.get(String(state.current)));
499
+ function renderViewAllFoot(footSel, total, cap, view, label) {
500
+ const foot = $(footSel);
501
+ if (!foot) return;
502
+ if (total > cap && state.current != null) {
503
+ foot.hidden = false;
504
+ foot.innerHTML = `<a class="pill-link" href="#/goal/${escapeHtml(String(state.current))}/${view}">View all ${total} ${label} →</a>`;
505
+ } else {
506
+ foot.hidden = true;
507
+ foot.innerHTML = '';
508
+ }
509
+ }
510
+ function renderConflicts() {
511
+ const pairs = state.conflictPairs;
512
+ const card = $('#goal-conflicts-card');
513
+ fillConflicts($('#goal-conflicts'), pairs == null ? null : pairs.slice(0, CONFLICT_CAP));
514
+ const total = pairs ? pairs.length : 0;
515
+ renderViewAllFoot('#goal-conflicts-foot', total, CONFLICT_CAP, 'conflicts', 'conflicts');
516
+ setCount('#goal-conflicts-count', total);
517
+ card?.classList.toggle('goal-conflicts-card--live', total > 0);
518
+ renderConflictsFull();
519
+ }
520
+ function renderConflictsFull() {
521
+ const pairs = state.conflictPairs;
522
+ const total = pairs ? pairs.length : 0;
523
+ fillConflicts($('#goal-conflicts-full'), pairs);
524
+ setCount('#goal-conflicts-view-count', total);
525
+ $('#goal-conflicts-view-card')?.classList.toggle('goal-conflicts-card--live', total > 0);
526
+ const back = $('#goal-conflicts-back');
527
+ if (back && state.current != null) back.setAttribute('href', `#/goal/${state.current}`);
528
+ }
529
+ const isPending = (c) => !c.satisfied && !!c.pending_review;
530
+ function completionSummaryHtml(g, crit, sat, ringSize) {
531
+ const pending = crit.filter(isPending).length;
532
+ // task 1002164 — an open goal used to punt confirmation to the /goal-review
533
+ // CLI skill here; confirming now happens on this page, so the line reports
534
+ // what is actually actionable instead of naming a command.
535
+ const openLine = pending
536
+ ? `${fmtNum(pending)} criteri${pending === 1 ? 'on is' : 'a are'} met and ready to confirm.`
537
+ : 'Open — a criterion becomes confirmable once every task linked to it has shipped.';
538
+ const line = g.status === 'achieved' ? 'Goal achieved — every criterion confirmed.'
539
+ : (g.status === 'archived' ? 'Archived.' : openLine);
540
+ return `<div style="display:flex;align-items:center;gap:16px">
541
+ ${ring(sat, crit.length, g.status === 'achieved', ringSize)}
542
+ <div>
543
+ <div style="font-weight:650">${sat} of ${crit.length} criteria satisfied</div>
544
+ <div style="font-size:12.5px;color:var(--ink-faint)">${line}</div>
545
+ </div>
546
+ </div>`;
547
+ }
548
+ function critListHtml(list, withDetail, canConfirm) {
549
+ if (!list.length) return emptyStateHtml('No done-when criteria yet.');
550
+ return `<ul class="crit-list${withDetail ? ' crit-list--full' : ''}">${list.map((c) => {
551
+ const parts = window.OTB.splitCriterion ? window.OTB.splitCriterion(c.criterion_md) : { label: c.criterion_md, detail: '' };
552
+ const body = withDetail
553
+ ? `<div class="crit__col"><span class="crit__text">${escapeHtml(parts.label || '')}</span>${parts.detail ? `<span class="crit__detail">${escapeHtml(parts.detail)}</span>` : ''}</div>`
554
+ : (parts.detail
555
+ ? `<details class="crit-d"><summary class="crit__text">${escapeHtml(parts.label || '')}</summary><div class="crit__detail">${escapeHtml(parts.detail)}</div></details>`
556
+ : `<span class="crit__text">${escapeHtml(parts.label || '')}</span>`);
557
+ const pending = isPending(c);
558
+ // A Xenos sees the state but not the control — the server enforces either way.
559
+ const action = !pending ? ''
560
+ : (canConfirm
561
+ ? `<button type="button" class="crit__confirm" data-confirm-crit="${escapeHtml(String(c.id))}"
562
+ title="Every task linked to this criterion has shipped — confirm it as satisfied">Confirm</button>`
563
+ : '<span class="crit__flag" title="Every task linked to this criterion has shipped — a Metic can confirm it">Ready</span>');
564
+ const cls = c.satisfied ? ' crit--done' : (pending ? ' crit--pending' : '');
565
+ return `<li class="crit${cls}"><span class="crit__mark">${c.satisfied ? '✓' : (pending ? '!' : '')}</span>${body}<span class="crit__act">${action}</span></li>`;
566
+ }).join('')}</ul>`;
567
+ }
568
+ function renderCompletion() {
569
+ const d = currentDetail();
570
+ if (!d) return;
571
+ const g = d.goal;
572
+ const crit = d.criteria || [];
573
+ const sat = crit.filter((c) => c.satisfied).length;
574
+ // task 1002164 — an archived/achieved goal has nothing left to confirm.
575
+ const canConfirm = g.status === 'open' && canConfirmCriteria();
576
+ const band = $('#goal-completion-body');
577
+ if (band) band.innerHTML = completionSummaryHtml(g, crit, sat, 56) + critListHtml(crit.slice(0, CRITERIA_CAP), false, canConfirm);
578
+ renderViewAllFoot('#goal-completion-foot', crit.length, CRITERIA_CAP, 'completion', 'criteria');
579
+ setCount('#goal-completion-count', crit.length);
580
+ const full = $('#goal-completion-full');
581
+ if (full) full.innerHTML = completionSummaryHtml(g, crit, sat, 64) + critListHtml(crit, true, canConfirm);
582
+ const back = $('#goal-completion-back');
583
+ if (back && state.current != null) back.setAttribute('href', `#/goal/${state.current}`);
584
+ }
585
+ function memberAvatar(m) {
586
+ const name = memberName(m);
587
+ const initials = String(name).replace(/^@/, '').trim().split(/\s+/).map((w) => w[0]).slice(0, 2).join('').toUpperCase();
588
+ return m.avatar_url ? `<img src="${escapeHtml(m.avatar_url)}" alt="">` : escapeHtml(initials);
589
+ }
590
+ function memberLi(m) {
591
+ return `<li class="member">
592
+ <span class="member__avatar">${memberAvatar(m)}</span>
593
+ <span class="member__name">${escapeHtml(memberName(m))} ${m.rank ? `<small>· ${escapeHtml(rankLabel(m.rank))}</small>` : ''}</span>
594
+ <span class="member-pill${m.membership_kind === 'lead' ? ' member-pill--lead' : ''}">${m.membership_kind === 'lead' ? 'Manager' : 'Member'}</span>
595
+ </li>`;
596
+ }
597
+ function goalMemberStats() {
598
+ const stats = new Map();
599
+ for (const t of state.work.done) {
600
+ if (t.shipped_by == null) continue;
601
+ const key = String(t.shipped_by);
602
+ const s = stats.get(key) || { shipped: 0, earned: 0 };
603
+ s.shipped += 1;
604
+ // Only SETTLED tasks have really-earned drachmae; a 'completed' (pre-confirm)
605
+ // task in state.work.done has not landed credits yet, so it contributes 0.
606
+ s.earned += taskSettled(t) ? creditVal(t) : 0;
607
+ stats.set(key, s);
608
+ }
609
+ return stats;
610
+ }
611
+ function memberStatLi(m, stats) {
612
+ const s = stats.get(String(m.builder_id)) || { shipped: 0, earned: 0 };
613
+ const cur = curLabel();
614
+ const statLine = state.workLoaded
615
+ ? `<strong>${fmtNum(s.shipped)}</strong> shipped · <strong>${fmtNum(s.earned)}</strong> ${escapeHtml(cur)}`
616
+ : '<span class="member__stats--pending">…</span>';
617
+ return `<li class="member member--stat">
618
+ <span class="member__avatar">${memberAvatar(m)}</span>
619
+ <span class="member__name">${escapeHtml(memberName(m))} ${m.rank ? `<small>· ${escapeHtml(rankLabel(m.rank))}</small>` : ''}</span>
620
+ <span class="member__stats" title="Contribution to this goal">${statLine}</span>
621
+ <span class="member-pill${m.membership_kind === 'lead' ? ' member-pill--lead' : ''}">${m.membership_kind === 'lead' ? 'Manager' : 'Member'}</span>
622
+ </li>`;
623
+ }
624
+ function renderMembers() {
625
+ const d = currentDetail();
626
+ if (!d) return;
627
+ const members = d.members || [];
628
+ const lede = $('#goal-members-lede');
629
+ if (lede) {
630
+ const leads = members.filter((m) => m.membership_kind === 'lead');
631
+ lede.textContent = leads.length
632
+ ? 'Leads manage this goal’s scope and (soon) resolve its conflicts.'
633
+ : 'No lead assigned yet — an Archon can appoint one.';
634
+ }
635
+ setCount('#goal-members-count', members.length);
636
+ const list = $('#goal-members');
637
+ if (list) {
638
+ list.innerHTML = members.length
639
+ ? members.slice(0, MEMBER_CAP).map(memberLi).join('')
640
+ : `<li>${emptyStateHtml('No members yet — be the first to join.')}</li>`;
641
+ }
642
+ renderViewAllFoot('#goal-members-foot', members.length, MEMBER_CAP, 'members', 'members');
643
+ renderMembersFull(d);
644
+ }
645
+ function renderMembersFull(d) {
646
+ const members = d.members || [];
647
+ setCount('#goal-members-view-count', members.length);
648
+ const stats = goalMemberStats();
649
+ const agg = $('#goal-members-stats');
650
+ if (agg) {
651
+ if (state.workLoaded) {
652
+ const shipped = state.work.done.length;
653
+ const earned = state.work.done.reduce((n, t) => n + (taskSettled(t) ? creditVal(t) : 0), 0);
654
+ const open = state.work.todo.length;
655
+ const cur = curLabel();
656
+ agg.innerHTML = `
657
+ <span class="mstat"><strong>${fmtNum(members.length)}</strong> member${members.length === 1 ? '' : 's'}</span>
658
+ <span class="mstat"><strong>${fmtNum(shipped)}</strong> task${shipped === 1 ? '' : 's'} shipped</span>
659
+ <span class="mstat"><strong>${fmtNum(earned)}</strong> ${escapeHtml(cur)} earned</span>
660
+ <span class="mstat"><strong>${fmtNum(open)}</strong> open</span>`;
661
+ } else {
662
+ agg.innerHTML = '<span class="mstat"><span class="kit-skel"></span></span>';
663
+ }
664
+ }
665
+ const full = $('#goal-members-full');
666
+ if (full) {
667
+ full.innerHTML = members.length
668
+ ? `<ul class="member-list">${members.map((m) => memberStatLi(m, stats)).join('')}</ul>`
669
+ : emptyStateHtml('No members yet — be the first to join.');
670
+ }
671
+ const back = $('#goal-members-back');
672
+ if (back && state.current != null) back.setAttribute('href', `#/goal/${state.current}`);
673
+ }
674
+ function renderDetail(goalId) {
675
+ const d = state.details.get(String(goalId));
676
+ if (!d) return;
677
+ const g = d.goal;
678
+ const crit = d.criteria || [];
679
+ const members = d.members || [];
680
+ const sat = crit.filter((c) => c.satisfied).length;
681
+ const joined = members.some((m) => sameId(m.builder_id, selfId()));
682
+ const iAmLead = members.some((m) => sameId(m.builder_id, selfId()) && m.membership_kind === 'lead');
683
+
684
+ const h = $('#goal-detail-h');
685
+ // task 2163 — surface the numeric goal id alongside the title (escaped title,
686
+ // so switching from textContent to innerHTML stays injection-safe).
687
+ if (h) h.innerHTML = `<span class="goal-detail__id" title="Goal id">#${escapeHtml(String(g.id))}</span> ${escapeHtml(g.title || 'Untitled goal')}`;
688
+ // task 1003224 — name / subtitle / Claude's prose at three weights; the rules live in goals-lib.
689
+ applyGoalProse({ sub: $('#goal-detail-subtitle'), desc: $('#goal-detail-desc'), claude: $('#goal-detail-claude') }, g);
690
+ const meta = $('#goal-detail-meta');
691
+ if (meta) {
692
+ const scope = Array.isArray(g.scope_modules) && g.scope_modules.length
693
+ ? `<span title="Module scope — tasks authored in this goal are bounded to these modules">scope: ${g.scope_modules.map(escapeHtml).join(', ')}</span>` : '';
694
+ // task 1761: this goal's prerequisite goals, each ✓ (achieved) or ○ (not yet).
695
+ // An open goal with any unmet prerequisite is flagged blocked.
696
+ const deps = d.dependencies || [];
697
+ const unmet = deps.filter((dep) => !dep.satisfied);
698
+ const depsSpan = deps.length
699
+ ? `<span class="goal-deps${(g.status === 'open' && unmet.length) ? ' goal-deps--blocked' : ''}" title="This goal is blocked until each prerequisite goal is achieved">${(g.status === 'open' && unmet.length) ? '🔒 ' : ''}depends on: ${deps.map((dep) => `${escapeHtml(dep.title || ('goal ' + dep.id))} ${dep.satisfied ? '✓' : '○'}`).join(', ')}</span>`
700
+ : '';
701
+ // task 1953 — a private goal is join-GATED, not hidden, so the lock badge is
702
+ // informational for every viewer (the toggle that flips it lives in Actions,
703
+ // manager-only).
704
+ const lockSpan = g.visibility === 'private'
705
+ ? '<span class="goal-badge goal-badge--locked" title="Private — join is by invitation or an approved request">🔒 Private</span>' : '';
706
+ // task 1002689 — a PROTECTED-scope goal is join-gated too (Archon-mediated
707
+ // membership, ADR 0086 §3): badge it so it doesn't read as open-join. The
708
+ // flag is server-derived (scope_protected on the goal payload) — the client
709
+ // still never re-derives the protected roster.
710
+ const gateSpan = g.scope_protected
711
+ ? '<span class="goal-badge goal-badge--locked" title="Protected module scope — join by request; an Archon admits members">🛡 Archon-mediated</span>' : '';
712
+ meta.innerHTML = `<span>${escapeHtml(g.version_id || '')}</span> ${statusBadge(g.status)} ${lockSpan} ${gateSpan} ${scope} ${depsSpan}`;
713
+ }
714
+
715
+ // task 1762: the goal's own owner/manager (or an Archon) can invite/manage
716
+ // without an Archon in the loop — mirrors the server's authorizeGoalManagement
717
+ // gate. A protected-scope goal stays Archon-only on the SERVER (goals.js);
718
+ // this client gate deliberately doesn't re-derive the protected-module list
719
+ // (that roster lives server-side only, module-scope-map.js) — a non-Archon
720
+ // owner/manager sees the control on a protected-scope goal and gets a clear
721
+ // toast if the server 403s (see inviteMember's error handling below).
722
+ const isOwner = sameId(g.created_by, selfId());
723
+ const canManage = g.status === 'open' && (isArchon() || isOwner || iAmLead);
724
+ renderActions(g, { joined, canManage });
725
+
726
+ // task 1828 — Completion + Members each render into their band column AND
727
+ // their dedicated full view (capped in the band, "View all →" into the view).
728
+ renderCompletion();
729
+ renderInviteBox(g.id, canManage, members);
730
+ renderMembers();
731
+
732
+ loadGoalTasks(g.id);
733
+ // task 1953 — my own pending request (any viewer) + the goal's pending queue
734
+ // (managers only); async, re-renders Actions + the pending panel on arrival.
735
+ loadGoalMembership(g.id, canManage);
736
+ void sat; void crit; // now read inside renderCompletion()
737
+ }
738
+ function renderActions(g, { joined, canManage }) {
739
+ const actions = $('#goal-detail-actions');
740
+ if (!actions) return;
741
+ const parts = [];
742
+ if (canManage) {
743
+ const nextVisibility = g.visibility === 'private' ? 'public' : 'private';
744
+ const label = g.visibility === 'private' ? '🔒 Private — make public' : 'Make private';
745
+ parts.push(`<button class="btn-ghost" id="goal-visibility-btn" type="button" data-next-visibility="${escapeHtml(nextVisibility)}">${label}</button>`);
746
+ }
747
+ if (g.status === 'open') {
748
+ if (joined) {
749
+ parts.push('<button class="btn-ghost" id="goal-leave-btn" type="button">Leave goal</button>');
750
+ } else if ((g.visibility === 'private' || g.scope_protected) && !isArchon()) {
751
+ if (state.myPendingRequest) {
752
+ parts.push('<span class="member-pill" title="Awaiting the owner/manager\'s approval">Requested</span>');
753
+ parts.push('<button class="btn-ghost" id="goal-withdraw-btn" type="button">Withdraw</button>');
754
+ } else {
755
+ parts.push('<button class="btn-ghost" id="goal-request-btn" type="button">🔒 Request to join</button>');
756
+ }
757
+ } else {
758
+ parts.push('<button class="landing__btn" id="goal-join-btn" type="button">Join goal</button>');
759
+ }
760
+ }
761
+ actions.innerHTML = parts.join(' ');
762
+ $('#goal-join-btn')?.addEventListener('click', () => joinLeave(g.id, 'join'));
763
+ $('#goal-leave-btn')?.addEventListener('click', () => joinLeave(g.id, 'leave'));
764
+ $('#goal-visibility-btn')?.addEventListener('click', (e) => toggleVisibility(g.id, e.currentTarget.dataset.nextVisibility));
765
+ $('#goal-request-btn')?.addEventListener('click', () => requestToJoin(g.id));
766
+ $('#goal-withdraw-btn')?.addEventListener('click', () => withdrawRequest(g.id, state.myPendingRequest && state.myPendingRequest.id));
767
+ }
768
+ async function loadActiveClaims() {
769
+ try {
770
+ const c = await getJSON(`${API}/tasks/claimable`);
771
+ const map = new Map();
772
+ for (const ac of (c.active_claims || [])) {
773
+ map.set(String(ac.task_id), { claim_id: ac.claim_id, builder_id: ac.builder_id, login: ac.github_login, name: ac.display_name });
774
+ }
775
+ state.claimsByTask = map;
776
+ } catch (_err) {
777
+ state.claimsByTask = new Map();
778
+ }
779
+ }
780
+ async function loadGoalTasks(goalId) {
781
+ try {
782
+ const [resp] = await Promise.all([
783
+ getJSON(`${API}/tasks?goal_id=${encodeURIComponent(goalId)}`),
784
+ loadActiveClaims(), // fills state.claimsByTask (best-effort)
785
+ ]);
786
+ if (!sameId(state.current, goalId)) return; // navigated away while loading
787
+ const tasks = resp.tasks || [];
788
+ state.work = {
789
+ todo: tasks.filter((t) => isTodoStatus(t.status)),
790
+ done: tasks.filter((t) => DONE_STATUSES.has(t.status))
791
+ .sort((a, b) => new Date(b.shipped_at || 0) - new Date(a.shipped_at || 0)),
792
+ };
793
+ state.workLoaded = true; // task 1828 — member stats can now compute
794
+ pruneGoalSelection(); // task 1002425 — drop ticks for cards that vanished (or a goal switch)
795
+ renderWork();
796
+ renderMembers(); // refresh per-member goal stats now that tasks landed
797
+ // Prefer the server-side detector when it exists (task 1736); any failure
798
+ // (404/501 pre-landing, or a transient error) falls back to the client
799
+ // estimate from declared touches[].
800
+ try {
801
+ const server = await getJSON(`${API}/goals/${encodeURIComponent(goalId)}/conflicts`);
802
+ if (!sameId(state.current, goalId)) return;
803
+ state.conflictPairs = (server.conflicts || []).map((c) => ({
804
+ a: c.task_a, b: c.task_b, titleA: c.title_a || '', titleB: c.title_b || '',
805
+ paths: c.overlapping_paths || [], severity: c.severity || null,
806
+ crossGoalTitle: c.cross_goal ? (c.goal_b_title || '') : '',
807
+ }));
808
+ state.conflictSource = 'server';
809
+ } catch (err) {
810
+ if (!sameId(state.current, goalId)) return;
811
+ state.conflictPairs = computeConflicts(tasks).map((c) => ({
812
+ a: c.a.id, b: c.b.id, titleA: c.a.title || '', titleB: c.b.title || '',
813
+ paths: c.paths, severity: null, crossGoalTitle: '',
814
+ }));
815
+ state.conflictSource = 'client';
816
+ }
817
+ renderConflicts();
818
+ } catch (err) {
819
+ if (err && err.message === 'redirecting to auth') return;
820
+ console.error('[goals] tasks load failed', err);
821
+ if (!sameId(state.current, goalId)) return;
822
+ // task 1002425 — To do is now a card list (#goal-todo-list), not a table.
823
+ const todoList = $('#goal-todo-list');
824
+ const doneBody = document.querySelector('#goal-done tbody');
825
+ if (todoList) todoList.innerHTML = emptyStateHtml('Tasks could not be loaded.');
826
+ if (doneBody) doneBody.innerHTML = '<tr><td colspan="5" class="ledger__empty">Tasks could not be loaded.</td></tr>';
827
+ state.conflictPairs = null;
828
+ state.conflictSource = null;
829
+ renderConflicts();
830
+ }
831
+ }
832
+ async function loadDirectory() {
833
+ if (state.directory) return state.directory;
834
+ try {
835
+ const d = await getJSON(`${API}/builders/directory`);
836
+ state.directory = d.builders || [];
837
+ } catch (err) {
838
+ state.directory = [];
839
+ }
840
+ return state.directory;
841
+ }
842
+ function renderInviteBox(goalId, canInvite, members) {
843
+ const box = $('#goal-invite');
844
+ if (!box) return;
845
+ if (!canInvite) {
846
+ box.hidden = true;
847
+ box.innerHTML = '';
848
+ state.inviteOpen = false;
849
+ return;
850
+ }
851
+ box.hidden = false;
852
+ if (!state.inviteOpen) {
853
+ box.innerHTML = '<button class="btn-ghost" id="goal-invite-open" type="button">+ Invite</button>';
854
+ $('#goal-invite-open')?.addEventListener('click', async () => {
855
+ state.inviteOpen = true;
856
+ await loadDirectory();
857
+ renderInviteBox(goalId, canInvite, members);
858
+ });
859
+ return;
860
+ }
861
+ const memberIds = new Set((members || []).map((m) => String(m.builder_id)));
862
+ const invitable = (state.directory || []).filter((b) => !memberIds.has(String(b.id)));
863
+ const options = invitable
864
+ .map((b) => `<option value="${escapeHtml(String(b.id))}">${escapeHtml(b.display_name || ('@' + b.github_login))}</option>`)
865
+ .join('');
866
+ box.innerHTML = `
867
+ <form class="goal-invite__form" id="goal-invite-form">
868
+ <select id="goal-invite-builder" required ${invitable.length ? '' : 'disabled'}>
869
+ ${invitable.length ? `<option value="" disabled selected>Choose a builder…</option>${options}`
870
+ : '<option value="">Everyone is already a member</option>'}
871
+ </select>
872
+ <select id="goal-invite-kind">
873
+ <option value="member">Member</option>
874
+ <option value="lead">Manager</option>
875
+ </select>
876
+ <button class="landing__btn" type="submit" ${invitable.length ? '' : 'disabled'}>Invite</button>
877
+ <button class="btn-ghost" id="goal-invite-cancel" type="button">Cancel</button>
878
+ </form>`;
879
+ $('#goal-invite-cancel')?.addEventListener('click', () => {
880
+ state.inviteOpen = false;
881
+ renderInviteBox(goalId, canInvite, members);
882
+ });
883
+ $('#goal-invite-form')?.addEventListener('submit', (ev) => {
884
+ ev.preventDefault();
885
+ const builderId = $('#goal-invite-builder')?.value;
886
+ const membershipKind = $('#goal-invite-kind')?.value || 'member';
887
+ if (builderId) inviteMember(goalId, builderId, membershipKind);
888
+ });
889
+ }
890
+ async function inviteMember(goalId, builderId, membershipKind) {
891
+ try {
892
+ // `membership_kind`, not `role` — ADR 0174 reserves that word for the three
893
+ // crafts (task 1003013).
894
+ await postJSON(`${API}/goals/${encodeURIComponent(goalId)}/members`, { builder_id: Number(builderId), membership_kind: membershipKind });
895
+ toast('Invited.');
896
+ state.inviteOpen = false;
897
+ await loadDetail(goalId, { force: true });
898
+ renderDetail(goalId);
899
+ renderList();
900
+ } catch (err) {
901
+ const code = err?.data?.error;
902
+ if (code === 'archon_required_for_protected_scope') toast('This goal is scoped to a protected module — only an Archon may add members.');
903
+ else if (code === 'metic_required_for_protected_scope') toast('A protected-scope goal requires a Metic+ member.');
904
+ else if (code === 'not_owner_or_manager') toast('Only this goal\'s owner or a manager (or an Archon) may invite.');
905
+ else if (code === 'goal_not_open') toast('This goal is not open.');
906
+ else toast('Could not invite that builder just now.');
907
+ }
908
+ }
909
+ async function joinLeave(goalId, action) {
910
+ try {
911
+ await postJSON(`${API}/goals/${encodeURIComponent(goalId)}/${action}`);
912
+ toast(action === 'join' ? 'Joined the goal.' : 'Left the goal.');
913
+ await loadDetail(goalId, { force: true });
914
+ renderDetail(goalId);
915
+ renderList();
916
+ } catch (err) {
917
+ const code = err?.data?.error;
918
+ if (code === 'archon_approval_required') toast('This goal is Archon-mediated — request to join instead.');
919
+ else if (code === 'goal_private') toast('This goal is private — request to join instead.');
920
+ else if (code === 'not_a_member') toast('You are not a member of this goal.');
921
+ else if (code === 'goal_not_open') toast('This goal is not open.');
922
+ else toast(`Could not ${action} just now.`);
923
+ }
924
+ }
925
+ async function toggleVisibility(goalId, visibility) {
926
+ try {
927
+ await patchJSON(`${API}/goals/${encodeURIComponent(goalId)}/visibility`, { visibility });
928
+ toast(visibility === 'private' ? 'Goal is now private.' : 'Goal is now public.');
929
+ await loadDetail(goalId, { force: true });
930
+ renderDetail(goalId);
931
+ renderList();
932
+ } catch (err) {
933
+ const code = err?.data?.error;
934
+ if (code === 'not_owner_or_manager') toast('Only this goal\'s owner or a manager (or an Archon) may do this.');
935
+ else if (code === 'goal_not_open') toast('Only an open goal can be managed.');
936
+ else toast('Could not update visibility just now.');
937
+ }
938
+ }
939
+ async function requestToJoin(goalId) {
940
+ try {
941
+ await postJSON(`${API}/goals/${encodeURIComponent(goalId)}/requests`, {});
942
+ toast('Request sent — the owner or a manager will review it.');
943
+ await loadDetail(goalId, { force: true });
944
+ renderDetail(goalId);
945
+ } catch (err) {
946
+ const code = err?.data?.error;
947
+ if (code === 'request_pending') toast('You already have a pending request or invitation for this goal.');
948
+ else if (code === 'already_a_member') toast('You are already a member of this goal.');
949
+ else if (code === 'goal_is_open') toast('This goal is open — joining directly instead.');
950
+ else toast('Could not send the request just now.');
951
+ }
952
+ }
953
+ async function withdrawRequest(goalId, reqId) {
954
+ if (reqId == null) return;
955
+ try {
956
+ await postJSON(`${API}/goals/${encodeURIComponent(goalId)}/requests/${encodeURIComponent(reqId)}/withdraw`, {});
957
+ toast('Request withdrawn.');
958
+ await loadDetail(goalId, { force: true });
959
+ renderDetail(goalId);
960
+ } catch (err) {
961
+ toast((err && err.data && err.data.message) || 'Could not withdraw that request just now.');
962
+ }
963
+ }
964
+ function pendingRequestWho(r) {
965
+ return r.display_name || (r.github_login ? `@${r.github_login}` : `builder #${r.builder_id}`);
966
+ }
967
+ function pendingRequestsHtml(rows) {
968
+ const requests = rows.filter((r) => r.kind === 'request');
969
+ const invites = rows.filter((r) => r.kind === 'invite');
970
+ const requestLi = (r) => `<li class="pending-row">
971
+ <span class="pending-row__who">${escapeHtml(pendingRequestWho(r))}</span>
972
+ ${r.note ? `<span class="pending-row__note">“${escapeHtml(r.note)}”</span>` : ''}
973
+ <span class="pending-row__actions">
974
+ <button class="btn-ghost" type="button" data-approve-req="${escapeHtml(String(r.id))}">Approve</button>
975
+ <button class="btn-ghost" type="button" data-deny-req="${escapeHtml(String(r.id))}">Deny</button>
976
+ </span>
977
+ </li>`;
978
+ const inviteLi = (r) => `<li class="pending-row">
979
+ <span class="pending-row__who">${escapeHtml(pendingRequestWho(r))}</span>
980
+ <span class="pending-row__note">invited — awaiting their response</span>
981
+ </li>`;
982
+ const parts = [];
983
+ if (requests.length) parts.push(`<div class="pending-group"><h3 class="pending-group__h">Join requests<span class="count-pill">${requests.length}</span></h3><ul class="pending-list">${requests.map(requestLi).join('')}</ul></div>`);
984
+ if (invites.length) parts.push(`<div class="pending-group"><h3 class="pending-group__h">Pending invitations<span class="count-pill">${invites.length}</span></h3><ul class="pending-list">${invites.map(inviteLi).join('')}</ul></div>`);
985
+ return parts.join('');
986
+ }
987
+ function renderPendingRequests() {
988
+ const box = $('#goal-pending');
989
+ if (!box) return;
990
+ const rows = state.pendingRequests;
991
+ if (!rows || !rows.length) { box.hidden = true; box.innerHTML = ''; return; }
992
+ box.hidden = false;
993
+ box.innerHTML = pendingRequestsHtml(rows);
994
+ }
995
+ async function loadGoalMembership(goalId, canManage) {
996
+ try {
997
+ const inbox = await getJSON(`${API}/goals/inbox`);
998
+ if (!sameId(state.current, goalId)) return;
999
+ state.myPendingRequest = (inbox.myPendingRequests || []).find((r) => sameId(r.goal_id, goalId)) || null;
1000
+ } catch (err) {
1001
+ if (err && err.message === 'redirecting to auth') return;
1002
+ if (!sameId(state.current, goalId)) return;
1003
+ state.myPendingRequest = null;
1004
+ }
1005
+ if (canManage) {
1006
+ try {
1007
+ const r = await getJSON(`${API}/goals/${encodeURIComponent(goalId)}/requests`);
1008
+ if (!sameId(state.current, goalId)) return;
1009
+ state.pendingRequests = r.requests || [];
1010
+ } catch (err) {
1011
+ if (err && err.message === 'redirecting to auth') return;
1012
+ if (!sameId(state.current, goalId)) return;
1013
+ state.pendingRequests = null;
1014
+ }
1015
+ } else {
1016
+ state.pendingRequests = null;
1017
+ }
1018
+ if (!sameId(state.current, goalId)) return;
1019
+ renderPendingRequests();
1020
+ const d = currentDetail();
1021
+ if (d) renderActions(d.goal, { joined: (d.members || []).some((m) => sameId(m.builder_id, selfId())), canManage });
1022
+ }
1023
+ async function loadDetail(goalId, { force = false } = {}) {
1024
+ if (!force && state.details.has(String(goalId))) return state.details.get(String(goalId));
1025
+ const d = await getJSON(`${API}/goals/${encodeURIComponent(goalId)}`);
1026
+ state.details.set(String(goalId), d);
1027
+ return d;
1028
+ }
1029
+
1030
+ return { GOAL_SORTS, WORK_SORT_HEADERS, errCode, errDetails, friendlyClaimError, goalSel, goalSelTotal, loadDetail, loadDirectory, loadGoalTasks, pagers, reflectSortHeaders, renderDetail, renderGoalBulkToolbar, renderList, renderTodoCards, renderWork };
1031
+ };
1032
+ })();