@bongos/core 1.19.262 → 1.19.264

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.
@@ -23,6 +23,14 @@
23
23
  (function () {
24
24
  'use strict';
25
25
  const { escapeHtml, $, fmtNum, fmtDate, toast, rankLabel } = window.OTB;
26
+ // Shared task-domain helpers (task 1002463). These lived in two copies here and
27
+ // in the other board and had already drifted — goals-page.js had silently lost
28
+ // friendlyBatchReason's MODULE_CONFLICT case. Destructured so every call site
29
+ // below reads exactly as before; the single copy lives in task-lib.js.
30
+ const {
31
+ priorityRank, touchesConflict, rankTagHtml, deHashRefs,
32
+ creditsPerMinute, buildPromptForTask, friendlyBatchReason,
33
+ } = window.OTBTaskLib;
26
34
  // Shared component kit (hall-kit.js, task 2166): pager, filter bar, tabs,
27
35
  // empty/skeleton states — adopted here by task 2171.
28
36
  const { makePager, paginate, wirePager, pagerBarHtml, makeTabs, makeFilterBar,
@@ -497,40 +505,10 @@
497
505
  // ===================================================================
498
506
 
499
507
  // Board-identical numeric helpers (work.js:214-240).
500
- function creditsPerMinute(t) {
501
- const c = Number(t.credits_reward); const m = Number(t.est_minutes);
502
- if (!c || !m || m <= 0) return -Infinity;
503
- return c / m;
504
- }
505
- function priorityRank(p) {
506
- if (!p) return 99;
507
- const m = /^P(\d+)/i.exec(String(p));
508
- return m ? Number(m[1]) : 99;
509
- }
510
508
  // Two tasks can't run in parallel if their touches[] overlap (exact path or a
511
509
  // directory-prefix either way); empty touches never conflict. Powers the
512
510
  // filter bar's "Run mode" control (work.js:230-239).
513
- function touchesConflict(aList, bList) {
514
- const norm = (p) => String(p).replace(/\/+$/, '');
515
- for (const a of (Array.isArray(aList) ? aList : [])) {
516
- const na = norm(a);
517
- for (const b of (Array.isArray(bList) ? bList : [])) {
518
- const nb = norm(b);
519
- if (na === nb || na.startsWith(nb + '/') || nb.startsWith(na + '/')) return true;
520
- }
521
- }
522
- return false;
523
- }
524
511
  // Per-task rank floor tag (work.js:247-254).
525
- function rankTagHtml(requiresRank) {
526
- const rr = (requiresRank || 'xenos').toLowerCase();
527
- if (rr === 'xenos') {
528
- return '<span class="task__tag task__tag--rank-open" title="No rank floor — any builder can claim this task">Any rank</span>';
529
- }
530
- const label = rr === 'archon' ? 'Archon' : rr.charAt(0).toUpperCase() + rr.slice(1) + '+';
531
- return `<span class="task__tag task__tag--rank" title="Minimum rank required to claim this task">${escapeHtml(label)}</span>`;
532
- }
533
-
534
512
  // Free-text search over the goal's todo list (work.js:261-274, minus the
535
513
  // goal-title match — a single goal makes that redundant).
536
514
  function todoSearchQuery() {
@@ -548,21 +526,6 @@
548
526
 
549
527
  // Copy-prompt (work.js:454-503). deHashRefs stops a bare #NNN autolinking to
550
528
  // GitHub in a pasted session; the prompt points the builder at the hall task URL.
551
- function deHashRefs(text) {
552
- return String(text == null ? '' : text).replace(/#(\d{1,5})\b/g, 'task $1');
553
- }
554
- function buildPromptForTask(t) {
555
- const desc = deHashRefs((t.description || '').trim());
556
- const hallBase = location.hostname.startsWith('builders.') ? location.origin : `${location.origin}/builders`;
557
- const hallUrl = `${hallBase}#/task/${t.id}`;
558
- return [
559
- `I am working on GDS task ${t.id} (${t.title}) — ${hallUrl}`,
560
- `Read the task description, follow its acceptance criteria, stay strictly within touches[], and ship via /builder-ship when done.`,
561
- ``,
562
- `Task description:`,
563
- desc || '(no description)',
564
- ].join('\n');
565
- }
566
529
  async function copyPromptForTask(taskId, btn) {
567
530
  const t = state.work.todo.find((x) => String(x.id) === String(taskId));
568
531
  if (!t) return;
@@ -697,21 +660,6 @@
697
660
  }
698
661
  }
699
662
  // A single batch-item failure reason → a readable fragment (work.js:545-562).
700
- function friendlyBatchReason(item) {
701
- switch (item && item.reason) {
702
- case 'TASK_NOT_FOUND': return 'no longer exists';
703
- case 'TASK_NOT_READY': return `no longer ready${item.currentStatus ? ` (${item.currentStatus})` : ''}`;
704
- case 'ALREADY_CLAIMED': return 'already claimed';
705
- case 'TOUCHES_CONFLICT': return `overlaps ${item.conflictTaskTitle || 'an active claim'}`;
706
- case 'BATCH_TOUCHES_CONFLICT': return `overlaps task #${item.conflictsWithTaskId} in this selection`;
707
- case 'DEPS_NOT_SHIPPED': return 'waits on unshipped dependencies';
708
- case 'rank_too_low_for_task':
709
- case 'XENOS_RESTRICTED':
710
- case 'INSUFFICIENT_RANK': return 'rank too low';
711
- case 'BUILDER_INACTIVE': return 'account inactive';
712
- default: return (item && item.reason) || 'not claimable';
713
- }
714
- }
715
663
  // Bulk claim the selected ready cards (work.js doBulkClaim, claims/batch is
716
664
  // all-or-nothing: a 409 batch_failed means nothing was claimed). Reloads the
717
665
  // goal's tasks either way so the cards reflect fresh truth.
@@ -194,6 +194,8 @@
194
194
  <script src="/builders/dom-utils.js?v=2026-06-14-domutils"></script>
195
195
  <!-- shared component kit (window.OTBKit) — pager, filter bar, tabs, empty/skeleton (task 2166). -->
196
196
  <script src="/builders/hall-kit.js"></script>
197
+ <!-- shared TASK-DOMAIN helpers (window.OTBTaskLib) — priority/touches/prompt/refusal-copy, one copy for both boards (task 1002463). -->
198
+ <script src="/builders/task-lib.js"></script>
197
199
  <script src="/builders/task-detail.js?v=2026-07-03-taskdetail"></script>
198
200
  <script src="/builders/brand-apply.js?v=2026-06-27-r75"></script>
199
201
  <script src="/builders/goals-page.js?v=2026-07-10-goals5"></script>
@@ -0,0 +1,136 @@
1
+ // modules/hall-ui/public/task-lib.js — the hall's shared TASK-DOMAIN helpers
2
+ // (task 1002463, from idea 1000567).
3
+ //
4
+ // WHY A SIBLING OF hall-kit.js RATHER THAN PART OF IT. hall-kit.js is explicitly
5
+ // "the SIX primitives every page redesign adopts" — pager, ledger, filter bar,
6
+ // tabs, section rail, empty/skeleton/collapsible. Those are UI *markup* factories,
7
+ // and modules/hall-ui/CLAUDE.md's "no bespoke pager / filter / tab / rail markup"
8
+ // rule is scoped to exactly that. The functions below are task-DOMAIN logic
9
+ // (how a task is ranked, whether two touches[] collide, what a refusal means in
10
+ // English). Folding them into the kit would blur a contract that is currently
11
+ // crisp, so they get their own namespace instead.
12
+ //
13
+ // Contract, mirrored from hall-kit.js so load order only needs "before the page
14
+ // script": dependency-free (own esc(), no window.OTB use), no eval-time DOM, and
15
+ // everything hangs off one global. Node-require-safe so it can be unit-tested.
16
+ //
17
+ // WHAT THIS FIXES. Before extraction these lived in TWO copies (work.js and
18
+ // goals-page.js) and had already drifted: goals-page.js's friendlyBatchReason had
19
+ // silently lost the MODULE_CONFLICT case that work.js handles, so a batch-claim
20
+ // refused for a module collision showed the raw code "MODULE_CONFLICT" to the
21
+ // builder on the Goals page and a sentence on the Tasks board. One copy, one
22
+ // behaviour, and the drift cannot re-open.
23
+ (() => {
24
+ 'use strict';
25
+
26
+ // Local HTML-escape — deliberately NOT window.OTB.escapeHtml, so this file
27
+ // keeps hall-kit's dependency-free property (same reasoning, same duplication
28
+ // of ~4 lines being cheaper than a load-order constraint).
29
+ function esc(s) {
30
+ return String(s ?? '').replace(/[&<>"']/g, (c) => ({
31
+ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;',
32
+ }[c]));
33
+ }
34
+
35
+ // Sort key for a "P3"-style priority string. Unparseable/absent sorts last.
36
+ function priorityRank(p) {
37
+ if (!p) return 99;
38
+ const m = /^P(\d+)/i.exec(String(p));
39
+ return m ? Number(m[1]) : 99;
40
+ }
41
+
42
+ // Do two touches[] lists collide? A path collides with itself and with anything
43
+ // under it, so "src/" conflicts with "src/gds/db.js". Trailing slashes are
44
+ // normalised first so "src" and "src/" are the same path.
45
+ function touchesConflict(aList, bList) {
46
+ const norm = (p) => String(p).replace(/\/+$/, '');
47
+ for (const a of (Array.isArray(aList) ? aList : [])) {
48
+ const na = norm(a);
49
+ for (const b of (Array.isArray(bList) ? bList : [])) {
50
+ const nb = norm(b);
51
+ if (na === nb || na.startsWith(nb + '/') || nb.startsWith(na + '/')) return true;
52
+ }
53
+ }
54
+ return false;
55
+ }
56
+
57
+ // The rank-floor chip. 'xenos' means no floor at all, which reads as an
58
+ // invitation ("Any rank") rather than a restriction.
59
+ function rankTagHtml(requiresRank) {
60
+ const rr = (requiresRank || 'xenos').toLowerCase();
61
+ if (rr === 'xenos') {
62
+ return '<span class="task__tag task__tag--rank-open" title="No rank floor — any builder can claim this task">Any rank</span>';
63
+ }
64
+ const label = rr === 'archon' ? 'Archon' : rr.charAt(0).toUpperCase() + rr.slice(1) + '+';
65
+ return `<span class="task__tag task__tag--rank" title="Minimum rank required to claim this task">${esc(label)}</span>`;
66
+ }
67
+
68
+ // Strip bare "#NNN" GDS refs from prose we are about to paste elsewhere: on
69
+ // GitHub a bare #NNN autolinks to an ISSUE, and our ids are not GitHub's, so
70
+ // the link 404s. (Same rule the repo enforces in committed markdown.)
71
+ // VERBATIM from the two originals — do not "tidy" either detail:
72
+ // • `\d{1,5}` (not `\d+`) is load-bearing by omission: a 6+ digit run never
73
+ // matches at all, so long numerics are left untouched rather than rewritten.
74
+ // • `text == null ? '' : text` (not `text || ''`) keeps falsy-but-real inputs:
75
+ // deHashRefs(0) must render "0", not "". `|| ''` would swallow it.
76
+ // Both were silently changed in an earlier draft of this extraction and the
77
+ // regression escaped an equivalence pass whose inputs were all strings/null.
78
+ function deHashRefs(text) {
79
+ return String(text == null ? '' : text).replace(/#(\d{1,5})\b/g, 'task $1');
80
+ }
81
+
82
+ // Reward density, for the "best value" sort. -Infinity (not 0) for anything
83
+ // unpriced or unestimated so those sink below every real ratio instead of
84
+ // tying with a genuinely zero-value task.
85
+ function creditsPerMinute(t) {
86
+ const c = Number(t.credits_reward);
87
+ const m = Number(t.est_minutes);
88
+ if (!c || !m || m <= 0) return -Infinity;
89
+ return c / m;
90
+ }
91
+
92
+ // The paste-into-your-agent prompt for one task.
93
+ function buildPromptForTask(t) {
94
+ const desc = deHashRefs((t.description || '').trim());
95
+ const hallBase = location.hostname.startsWith('builders.') ? location.origin : `${location.origin}/builders`;
96
+ const hallUrl = `${hallBase}#/task/${t.id}`;
97
+ return [
98
+ `I am working on GDS task ${t.id} (${t.title}) — ${hallUrl}`,
99
+ `Read the task description, follow its acceptance criteria, stay strictly within touches[], and ship via /builder-ship when done.`,
100
+ ``,
101
+ `Task description:`,
102
+ desc || '(no description)',
103
+ ].join('\n');
104
+ }
105
+
106
+ // Turn a batch-claim refusal into a sentence. THE DEFAULT MATTERS: an unmapped
107
+ // code falls through to the raw string, which is how the missing MODULE_CONFLICT
108
+ // case leaked a machine code into the Goals page UI. Adding a server-side reason
109
+ // without adding it here degrades quietly rather than loudly, so keep this in
110
+ // step with the claim route's refusal codes.
111
+ function friendlyBatchReason(item) {
112
+ switch (item && item.reason) {
113
+ case 'TASK_NOT_FOUND': return 'no longer exists';
114
+ case 'TASK_NOT_READY': return `no longer ready${item.currentStatus ? ` (${item.currentStatus})` : ''}`;
115
+ case 'ALREADY_CLAIMED': return 'already claimed';
116
+ case 'TOUCHES_CONFLICT': return `overlaps ${item.conflictTaskTitle || 'an active claim'}`;
117
+ case 'BATCH_TOUCHES_CONFLICT': return `overlaps task #${item.conflictsWithTaskId} in this selection`;
118
+ case 'MODULE_CONFLICT': return item.conflictsWithTaskId
119
+ ? `shares module "${item.module_key}" with task #${item.conflictsWithTaskId} in this selection`
120
+ : `shares module "${item.module_key}" with an active claim`;
121
+ case 'DEPS_NOT_SHIPPED': return 'waits on unshipped dependencies';
122
+ case 'rank_too_low_for_task':
123
+ case 'XENOS_RESTRICTED':
124
+ case 'INSUFFICIENT_RANK': return 'rank too low';
125
+ case 'BUILDER_INACTIVE': return 'account inactive';
126
+ default: return (item && item.reason) || 'not claimable';
127
+ }
128
+ }
129
+
130
+ const api = {
131
+ priorityRank, touchesConflict, rankTagHtml, deHashRefs,
132
+ creditsPerMinute, buildPromptForTask, friendlyBatchReason,
133
+ };
134
+ if (typeof window !== 'undefined') window.OTBTaskLib = api;
135
+ if (typeof module !== 'undefined' && module.exports) module.exports = api;
136
+ })();
@@ -135,6 +135,8 @@
135
135
  <script src="/builders/dom-utils.js?v=2026-06-14-domutils" defer></script>
136
136
  <!-- shared component kit (window.OTBKit) — tabs, pager, filter bar (task 2166). -->
137
137
  <script src="/builders/hall-kit.js" defer></script>
138
+ <!-- shared TASK-DOMAIN helpers (window.OTBTaskLib) — priority/touches/prompt/refusal-copy, one copy for both boards (task 1002463). -->
139
+ <script src="/builders/task-lib.js" defer></script>
138
140
  <script src="/builders/task-detail.js?v=2026-07-03-taskdetail" defer></script>
139
141
  <script src="/builders/brand-apply.js?v=2026-06-19-r56" defer></script>
140
142
  <script src="/builders/work.js?v=2026-07-03-workboard3" defer></script>
@@ -36,6 +36,14 @@
36
36
  // file's exact variants (date-only fmtDate; a 2200ms clearing toast).
37
37
  // fmtAgo / toAuth / getJSON / postJSON stay local below.
38
38
  const { escapeHtml, $, fmtNum } = window.OTB;
39
+ // Shared task-domain helpers (task 1002463). These lived in two copies here and
40
+ // in the other board and had already drifted — goals-page.js had silently lost
41
+ // friendlyBatchReason's MODULE_CONFLICT case. Destructured so every call site
42
+ // below reads exactly as before; the single copy lives in task-lib.js.
43
+ const {
44
+ priorityRank, touchesConflict, rankTagHtml,
45
+ creditsPerMinute, buildPromptForTask, friendlyBatchReason,
46
+ } = window.OTBTaskLib; // deHashRefs is not called here — it is used inside buildPromptForTask
39
47
  // Shared component kit (hall-kit.js, task 2166): tabs, pager, filter bar.
40
48
  const { makePager, paginate, wirePager, pagerBarHtml, makeTabs, makeFilterBar } = window.OTBKit;
41
49
  const fmtDate = (iso) => window.OTB.fmtDate(iso);
@@ -211,48 +219,14 @@
211
219
  const sequentialCache = { key: '', order: null };
212
220
  let sequentialDebounce = null;
213
221
 
214
- function creditsPerMinute(t) {
215
- const c = Number(t.credits_reward);
216
- const m = Number(t.est_minutes);
217
- if (!c || !m || m <= 0) return -Infinity;
218
- return c / m;
219
- }
220
-
221
- function priorityRank(p) {
222
- if (!p) return 99;
223
- const m = /^P(\d+)/i.exec(String(p));
224
- return m ? Number(m[1]) : 99;
225
- }
226
-
227
222
  // task 1756: two tasks can't run in parallel if their touches[] overlap — exact
228
223
  // path match or a directory-prefix either way (mirrors the server's touchesOverlap
229
224
  // / the goals-page conflict estimate). Empty touches never conflict.
230
- function touchesConflict(aList, bList) {
231
- const norm = (p) => String(p).replace(/\/+$/, '');
232
- for (const a of (Array.isArray(aList) ? aList : [])) {
233
- const na = norm(a);
234
- for (const b of (Array.isArray(bList) ? bList : [])) {
235
- const nb = norm(b);
236
- if (na === nb || na.startsWith(nb + '/') || nb.startsWith(na + '/')) return true;
237
- }
238
- }
239
- return false;
240
- }
241
-
242
225
  // task 1752 (+ 1498 / ADR 0084): the minimum rank to claim a task, as a tag shown
243
226
  // on EVERY task row. 'xenos' is the open default → a muted "Any rank" tag (open to
244
227
  // all, not a warning); 'archon' shows bare; other ranks show 'Rank+'. Kept here for
245
228
  // now; the shared task-detail view (task 1753) will lift this into the common
246
229
  // toolkit so every task surface renders rank identically.
247
- function rankTagHtml(requiresRank) {
248
- const rr = (requiresRank || 'xenos').toLowerCase();
249
- if (rr === 'xenos') {
250
- return '<span class="task__tag task__tag--rank-open" title="No rank floor — any builder can claim this task">Any rank</span>';
251
- }
252
- const label = rr === 'archon' ? 'Archon' : rr.charAt(0).toUpperCase() + rr.slice(1) + '+';
253
- return `<span class="task__tag task__tag--rank" title="Minimum rank required to claim this task">${escapeHtml(label)}</span>`;
254
- }
255
-
256
230
  // task 1829: free-text search over the claimable/backlog lists. Matches the
257
231
  // query (case-insensitive substring) against the task id (with or without a
258
232
  // leading '#'), the title, and the goal title (taskState.goalTitles — the
@@ -455,23 +429,6 @@
455
429
  // our records number tasks separately from GitHub. So the scroll we hand the
456
430
  // builder must never carry a bare "#NNN": write "task NNN" + the clickable hall
457
431
  // URL, and strip "#NNN" out of the (free-text) task description too.
458
- function deHashRefs(text) {
459
- return String(text == null ? '' : text).replace(/#(\d{1,5})\b/g, 'task $1');
460
- }
461
-
462
- function buildPromptForTask(t) {
463
- const desc = deHashRefs((t.description || '').trim());
464
- const hallBase = location.hostname.startsWith('builders.') ? location.origin : `${location.origin}/builders`;
465
- const hallUrl = `${hallBase}#/task/${t.id}`;
466
- return [
467
- `I am working on GDS task ${t.id} (${t.title}) — ${hallUrl}`,
468
- `Read the task description, follow its acceptance criteria, stay strictly within touches[], and ship via /builder-ship when done.`,
469
- ``,
470
- `Task description:`,
471
- desc || '(no description)',
472
- ].join('\n');
473
- }
474
-
475
432
  async function copyPromptForTask(taskId, btn) {
476
433
  const t = taskState.all.find((x) => String(x.id) === String(taskId));
477
434
  if (!t) return;
@@ -546,25 +503,6 @@
546
503
  // /claims/batch/validate) to a builder-readable fragment. Distinct from
547
504
  // friendlyActionError above because a batch item's shape differs (reason,
548
505
  // not error; extra fields like conflictsWithTaskId/module_key).
549
- function friendlyBatchReason(item) {
550
- switch (item && item.reason) {
551
- case 'TASK_NOT_FOUND': return 'no longer exists';
552
- case 'TASK_NOT_READY': return `no longer ready${item.currentStatus ? ` (${item.currentStatus})` : ''}`;
553
- case 'ALREADY_CLAIMED': return 'already claimed';
554
- case 'TOUCHES_CONFLICT': return `overlaps ${item.conflictTaskTitle || 'an active claim'}`;
555
- case 'BATCH_TOUCHES_CONFLICT': return `overlaps task #${item.conflictsWithTaskId} in this selection`;
556
- case 'MODULE_CONFLICT': return item.conflictsWithTaskId
557
- ? `shares module "${item.module_key}" with task #${item.conflictsWithTaskId} in this selection`
558
- : `shares module "${item.module_key}" with an active claim`;
559
- case 'DEPS_NOT_SHIPPED': return 'waits on unshipped dependencies';
560
- case 'rank_too_low_for_task':
561
- case 'XENOS_RESTRICTED':
562
- case 'INSUFFICIENT_RANK': return 'rank too low';
563
- case 'BUILDER_INACTIVE': return 'account inactive';
564
- default: return (item && item.reason) || 'not claimable';
565
- }
566
- }
567
-
568
506
  async function copyText(text) {
569
507
  try { await navigator.clipboard.writeText(text); return true; }
570
508
  catch (_) {
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.262",
3
+ "version": "1.19.264",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.262",
9
+ "version": "1.19.264",
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.262",
3
+ "version": "1.19.264",
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",
@@ -729,7 +729,7 @@ async function uploadSessionOnShip(taskId) {
729
729
  recordUploadSkip(taskId, {
730
730
  code: `http_${r.status}`,
731
731
  reason:
732
- `POST /api/gds/sessions returned ${r.status}${r.data && r.data.error ? ` (${r.data.error})` : ''} — ` +
732
+ `POST /api/gds/sessions returned ${r.status}${r.data && r.data.error ? ` (${apiErrorLine(r.data)})` : ''} — ` +
733
733
  'the API rejected or could not store the digest. This ship will NOT appear in the BFG session corpus.',
734
734
  });
735
735
  }
@@ -1268,7 +1268,7 @@ async function ciLandServer(taskId, branch, valueSummary) {
1268
1268
  id: taskId,
1269
1269
  body: { branch, head_sha: headSha, value_summary: valueSummary, bundle_b64: bundleB64 },
1270
1270
  });
1271
- if (r.status === 403 && r.data && r.data.error === 'box_scope') {
1271
+ if (r.status === 403 && normalizeApiError(r.data).code === 'box_scope') {
1272
1272
  console.log(' ci-land: this session is BOX-SCOPED — run /builder-reauth (browser, no terminal) to mint a real session, then re-ship. Task stays at confirmed.');
1273
1273
  return landBailed('this CLI session is box-scoped', '/builder-reauth');
1274
1274
  }
@@ -3160,9 +3160,39 @@ function normalizeApiError(data) {
3160
3160
  return {
3161
3161
  code: isEnvelope ? errRaw.code : errRaw,
3162
3162
  message: isEnvelope ? errRaw.message : undefined,
3163
+ // task 1002789: `details` is where the envelope carries the structured
3164
+ // payload the flat shape used to put at the TOP level (claimWorktree,
3165
+ // step/reason, floors). A site that reads `data.<field>` directly is
3166
+ // reading a field that moved. Fall back to the body so a legacy flat
3167
+ // response still resolves.
3168
+ details: (isEnvelope && errRaw.details && typeof errRaw.details === 'object')
3169
+ ? errRaw.details
3170
+ : (data && typeof data === 'object' ? data : {}),
3163
3171
  };
3164
3172
  }
3165
3173
 
3174
+ // task 1002789: the ONE renderer for a failed API call on the ship path.
3175
+ // Every generic bail site used to hand the raw body to console.error (or
3176
+ // interpolate `data.error` into a template, which stringifies the ADR 0116
3177
+ // envelope object to the literal "[object Object]"). Both hide a fully
3178
+ // diagnosable server error: console.error's util.inspect stops at depth 2, so
3179
+ // the `details` that names WHICH git step failed prints as "[Object]".
3180
+ // Collapse the body to one readable line instead, whichever shape it is.
3181
+ // Pure + exported for tests/ship_error_shape.mjs.
3182
+ function apiErrorLine(data, status) {
3183
+ const { code, message, details } = normalizeApiError(data);
3184
+ const head = code
3185
+ ? (message && message !== code ? `${code}: ${message}` : code)
3186
+ : (message || (status ? `HTTP ${status}` : 'unknown error'));
3187
+ // Only the small scalar fields — a details blob can carry a whole bundle.
3188
+ const extras = Object.entries(details || {})
3189
+ .filter(([k, v]) => k !== 'code' && k !== 'message' && k !== 'error'
3190
+ && (typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean'))
3191
+ .map(([k, v]) => `${k}: ${String(v).replace(/\s+/g, ' ').trim()}`);
3192
+ const line = extras.length ? `${head} (${extras.join('; ')})` : head;
3193
+ return status ? `${line} [HTTP ${status}]` : line;
3194
+ }
3195
+
3166
3196
  // task 1002809: classify a FAILED post-land POST /tasks/<id>/ship. The merge
3167
3197
  // already succeeded, so the server may simply have landed the task first
3168
3198
  // (reconcile-on-read — task 1049 — or the ~5-min reconciler racing this call).
@@ -4068,13 +4098,14 @@ async function shipMain() {
4068
4098
  },
4069
4099
  });
4070
4100
  if (!r.ok) {
4071
- if (r.data?.error === 'CLAIM_SESSION_MISMATCH') {
4072
- const wt = r.data.claimWorktree;
4101
+ const resolveErr = normalizeApiError(r.data);
4102
+ if (resolveErr.code === 'CLAIM_SESSION_MISMATCH') {
4103
+ const wt = resolveErr.details.claimWorktree;
4073
4104
  console.error(`This claim was created in a different session${wt ? ` (worktree: ${wt})` : ''}.`);
4074
4105
  console.error('You can only ship from the session that originally claimed this task.');
4075
4106
  console.error(`To force-release from this session: node scripts/gds/release.js ${taskId} --force`);
4076
4107
  } else {
4077
- console.error(`resolve failed (${r.status}):`, r.data);
4108
+ console.error(`resolve failed: ${apiErrorLine(r.data, r.status)}`);
4078
4109
  }
4079
4110
  process.exit(1);
4080
4111
  }
@@ -4110,7 +4141,7 @@ async function shipMain() {
4110
4141
  // verify ran). --verified is a no-op for every other kind.
4111
4142
  const confirm = await (await cliClient()).tasks.postTasksIdConfirm({ id: taskId, body: { verified } });
4112
4143
  if (!confirm.ok) {
4113
- if (confirm.status === 409 && confirm.data && confirm.data.error === 'verify_attestation_required') {
4144
+ if (confirm.status === 409 && normalizeApiError(confirm.data).code === 'verify_attestation_required') {
4114
4145
  console.error('');
4115
4146
  console.error(`This is a VERIFICATION task — it won't confirm until you attest the verify actually passed.`);
4116
4147
  console.error(`Run the verification, then re-ship with --verified:`);
@@ -4139,7 +4170,7 @@ async function shipMain() {
4139
4170
  console.error(` task.confirm.grade_bypass${floorNote} to POST /api/gds/tasks/${taskId}/confirm.`);
4140
4171
  process.exit(1);
4141
4172
  }
4142
- console.error(`manual-confirm failed (${confirm.status}):`, confirm.data);
4173
+ console.error(`manual-confirm failed: ${apiErrorLine(confirm.data, confirm.status)}`);
4143
4174
  process.exit(1);
4144
4175
  }
4145
4176
  postGradeStatus = confirm.data.taskStatus;
@@ -4490,6 +4521,9 @@ module.exports = {
4490
4521
  // 'pending' (success), not failure. Exported so the semantics are unit-tested.
4491
4522
  classifyLandOutcome,
4492
4523
  normalizeApiError,
4524
+ // task 1002789: the single one-line renderer for a failed API call. Exported
4525
+ // so the "[object Object]" regression is pinned against BOTH wire shapes.
4526
+ apiErrorLine,
4493
4527
  postLandShipOutcome,
4494
4528
  // task 1718: pure poll-path decision — enable-failure polls the SERVER (not a
4495
4529
  // dead-end strand at confirmed), enable-success watches GitHub. Exported so the
package/src/module-api.js CHANGED
@@ -951,8 +951,12 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
951
951
  // landed since 1.19.260 with no explicit bump. run 32295699354. (task 1002620)
952
952
  // 1.19.262 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
953
953
  // landed since 1.19.261 with no explicit bump. run 32298063660. (task 1002620)
954
+ // 1.19.263 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
955
+ // landed since 1.19.262 with no explicit bump. run 32305228786. (task 1002620)
956
+ // 1.19.264 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
957
+ // landed since 1.19.263 with no explicit bump. run 32396082154. (task 1002620)
954
958
  // ---------------------------------------------------------------------------
955
- const CORE_VERSION = '1.19.262'; // CI auto-patch carrier (ADR 0161); see changelog above
959
+ const CORE_VERSION = '1.19.264'; // CI auto-patch carrier (ADR 0161); see changelog above
956
960
 
957
961
  // A namespaced logger so a module's log lines are attributable + consistent.
958
962
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -32,6 +32,11 @@ import { makeRunner } from './helpers.mjs';
32
32
 
33
33
  const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
34
34
  const SRC = fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public/goals-page.js'), 'utf8');
35
+ // task 1002463: goals-page.js now pulls its task-domain helpers from the shared
36
+ // window.OTBTaskLib (task-lib.js) instead of holding its own drifted copies. Run
37
+ // the REAL file in the same context rather than stubbing it — stubbing would let
38
+ // the extraction rot without this, the only test that executes either board.
39
+ const TASK_LIB_SRC = fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public/task-lib.js'), 'utf8');
35
40
 
36
41
  const { test, summary } = makeRunner();
37
42
 
@@ -126,6 +131,7 @@ async function render({ rank = 'metic', goalStatus = 'open', criteria = Object.v
126
131
  const sandbox = { window: windowObj, document: documentObj, location: windowObj.location, console, setTimeout, clearTimeout, Set, Map, Number, String, Array, Object, JSON, Math, encodeURIComponent, Promise };
127
132
  sandbox.globalThis = sandbox;
128
133
  vm.createContext(sandbox);
134
+ vm.runInContext(TASK_LIB_SRC, sandbox, { filename: 'task-lib.js' });
129
135
  vm.runInContext(SRC, sandbox, { filename: 'goals-page.js' });
130
136
 
131
137
  // Let boot()'s promise chain settle (me → goals → details → route → render).