@bongos/core 1.19.1002 → 1.19.1004

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.
@@ -2478,5 +2478,9 @@ is load-bearing: the script throws rather than guess if it is missing, and
2478
2478
  landed since 1.19.1000 with no explicit bump. run 36016738965. (task 1002620)
2479
2479
  1.19.1002 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2480
2480
  landed since 1.19.1001 with no explicit bump. run 36019839845. (task 1002620)
2481
+ 1.19.1003 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2482
+ landed since 1.19.1002 with no explicit bump. run 36028273772. (task 1002620)
2483
+ 1.19.1004 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2484
+ landed since 1.19.1003 with no explicit bump. run 36030644887. (task 1002620)
2481
2485
  ---------------------------------------------------------------------------
2482
2486
  ```
@@ -92,7 +92,44 @@
92
92
  // ideas slice (#attention-ideas) belongs to builders.js's triage renderer.
93
93
  // Card visibility + the headline count derive from BOTH via OTBAttention.
94
94
  const MAX_GOAL_ROWS = 5;
95
+
96
+ // task 1001741 (part 3): goals I own or lead whose open tasks collide while
97
+ // live work is involved. The server already limited this to medium/high
98
+ // pairs on goals the viewer manages, so a row here is always actionable: the
99
+ // resolve actions (sequence / request release / rescope) live on the goal's
100
+ // conflicts view, which the row links to. Advisory — it never blocks work.
101
+ // A title is builder-authored prose; the card writes the sentence around it, so a
102
+ // dash inside it is normalised the way the public feed does (cleanTitle).
103
+ const cleanTitle = (raw) => String(raw == null ? '' : raw).replace(/\s*[—–]\s*/g, ', ').replace(/,\s*,/g, ',').trim();
104
+ function conflictRow(c) {
105
+ const n = Number(c.count) || 0;
106
+ const live = Number(c.high) > 0
107
+ ? `${c.high} with both sides claimed`
108
+ : 'in each, one side is already claimed';
109
+ const top = c.top
110
+ ? `<p class="ginbox__note">e.g. “${escapeHtml(cleanTitle(c.top.title_a))}” and “${escapeHtml(cleanTitle(c.top.title_b))}”</p>`
111
+ : '';
112
+ return `<li class="ginbox__item">
113
+ <div class="ginbox__body">
114
+ <a class="ginbox__goal" href="/goals#/goal/${encodeURIComponent(c.goal_id)}/conflicts" title="Open the conflicts for ${escapeHtml(cleanTitle(c.goal_title) || 'goal')}">${escapeHtml(cleanTitle(c.goal_title) || 'a goal')}</a>
115
+ <p class="ginbox__facts">${n} task ${n === 1 ? 'pair overlaps' : 'pairs overlap'} on live work — ${escapeHtml(live)}. Sequence them before two builders collide.</p>
116
+ ${top}
117
+ </div>
118
+ </li>`;
119
+ }
120
+
121
+ function renderConflicts(list) {
122
+ const box = host && host.querySelector('#attention-conflicts');
123
+ if (!box) return;
124
+ const rows = Array.isArray(list) ? list : [];
125
+ box.innerHTML = rows.length
126
+ ? `<h3 class="ginbox__sec-h">File conflicts in your goals</h3><ul class="ginbox">${rows.slice(0, MAX_GOAL_ROWS).map(conflictRow).join('')}</ul>`
127
+ + (rows.length > MAX_GOAL_ROWS ? `<p class="ginbox__pending">…and ${rows.length - MAX_GOAL_ROWS} more goals — <a href="/goals">open your goals</a>.</p>` : '')
128
+ : '';
129
+ }
130
+
95
131
  function render(inbox) {
132
+ renderConflicts(inbox.conflicts);
96
133
  const box = host && host.querySelector('#attention-goals');
97
134
  if (!box) return;
98
135
  const invitations = inbox.invitations || [];
@@ -135,6 +172,7 @@
135
172
  // nothing in the goals slice; the ideas slice may still have content.
136
173
  const box = host.querySelector('#attention-goals');
137
174
  if (box) box.innerHTML = '';
175
+ renderConflicts([]);
138
176
  window.OTBAttention.refresh();
139
177
  if (err.code !== 'auth-needed' && err.code !== 'not-found') {
140
178
  console.error('[hall] goal inbox load failed', err);
@@ -565,6 +603,9 @@
565
603
  // refresh() counts them.
566
604
  '<div id="attention-system"></div>' +
567
605
  '<div id="attention-goals"></div>' +
606
+ // task 1001741: file conflicts between open tasks in goals the viewer
607
+ // owns or leads — filled from the same /goals/inbox read as the slice above.
608
+ '<div id="attention-conflicts"></div>' +
568
609
  // task 1002802 (P06): "Complete your profile" — a DISMISSIBLE nudge when
569
610
  // bio or preferred_disciplines are empty. Rendered by builders.js (which
570
611
  // already holds the /me state the condition reads), exactly like the
@@ -2746,6 +2746,15 @@ main [id] { scroll-margin-top: calc(var(--topbar-h) + 16px); }
2746
2746
  cursor: help;
2747
2747
  }
2748
2748
 
2749
+ /* task 1001741: per-row file-overlap flag. Amber, never red — it is advisory
2750
+ (ADR 0049) and blocks nothing. */
2751
+ .task__conflict {
2752
+ font-size: 11.5px;
2753
+ white-space: nowrap;
2754
+ color: var(--warn);
2755
+ cursor: help;
2756
+ }
2757
+
2749
2758
  /* task 1753: the whole card is clickable → toggles selection (task 1869). */
2750
2759
  .task { cursor: pointer; }
2751
2760
 
@@ -54,6 +54,26 @@
54
54
  return false;
55
55
  }
56
56
 
57
+ // task 1001741: which OTHER tasks in `pool` collide with `task`? The board's
58
+ // per-row conflict flag — the same predicate as the run-mode filter, but named
59
+ // per row so the row itself says what it collides with. Returns ids, sorted.
60
+ function conflictPeers(task, pool) {
61
+ if (!task || !Array.isArray(task.touches) || !task.touches.length) return [];
62
+ const ids = new Set();
63
+ for (const o of (Array.isArray(pool) ? pool : [])) {
64
+ if (o && String(o.id) !== String(task.id) && touchesConflict(task.touches, o.touches)) ids.add(Number(o.id));
65
+ }
66
+ return [...ids].sort((a, b) => a - b);
67
+ }
68
+
69
+ // Chip HTML for a row's conflict peers; '' when there are none. Advisory, like
70
+ // every touches[] signal (ADR 0049) — it never blocks a claim.
71
+ function conflictChipHtml(peers) {
72
+ if (!peers || !peers.length) return '';
73
+ const shown = peers.slice(0, 5).map((n) => `#${n}`).join(', ') + (peers.length > 5 ? ', …' : '');
74
+ return `<span class="task__conflict" title="Declared files overlap: ${esc(shown)} (advisory)">⚠ overlaps ${peers.length}</span>`;
75
+ }
76
+
57
77
  // The rank-floor chip. 'xenos' means no floor at all, which reads as an
58
78
  // invitation ("Any rank") rather than a restriction.
59
79
  function rankTagHtml(requiresRank) {
@@ -161,7 +181,7 @@
161
181
 
162
182
  const api = {
163
183
  msOf, taskActivityMs, STALE_CHOICES, isStale,
164
- priorityRank, touchesConflict, rankTagHtml, deHashRefs,
184
+ priorityRank, touchesConflict, conflictPeers, conflictChipHtml, rankTagHtml, deHashRefs,
165
185
  creditsPerMinute, buildPromptForTask, friendlyBatchReason,
166
186
  };
167
187
  if (typeof window !== 'undefined') window.OTBTaskLib = api;
@@ -41,7 +41,7 @@
41
41
  // friendlyBatchReason's MODULE_CONFLICT case. Destructured so every call site
42
42
  // below reads exactly as before; the single copy lives in task-lib.js.
43
43
  const {
44
- priorityRank, touchesConflict, rankTagHtml,
44
+ priorityRank, touchesConflict, conflictPeers, conflictChipHtml, rankTagHtml,
45
45
  creditsPerMinute, buildPromptForTask, friendlyBatchReason,
46
46
  } = window.OTBTaskLib; // deHashRefs is not called here — it is used inside buildPromptForTask
47
47
  // Shared component kit (hall-kit.js, task 2166): tabs, pager, filter bar.
@@ -379,6 +379,12 @@
379
379
  if (mine) mine.setTab(tabId);
380
380
  }
381
381
 
382
+ // task 1001741: the set a row is checked against for its conflict flag — every
383
+ // open task this page has loaded (Ready + Backlog), not just the filtered view.
384
+ function conflictPool() {
385
+ return taskState.all.concat(backlogRows);
386
+ }
387
+
382
388
  function renderClaimable() {
383
389
  const root = $('#task-list');
384
390
  if (!root) return;
@@ -425,6 +431,7 @@
425
431
  // RESETS the page (only an explicit filter/sort/per-page change does), so a
426
432
  // background poll keeps the viewer on their current page.
427
433
  const shown = paginate(list, pagers.ready);
434
+ const pool = conflictPool();
428
435
  root.innerHTML = shown.map((t) => {
429
436
  const cpm = creditsPerMinute(t);
430
437
  const cpmText = Number.isFinite(cpm) ? `${cpm.toFixed(2)} d/min` : '— d/min';
@@ -472,6 +479,7 @@
472
479
  <span>${minutesText}</span>
473
480
  <span>${cpmText}</span>
474
481
  ${touchesChip}
482
+ ${conflictChipHtml(conflictPeers(t, pool))}
475
483
  ${ageChip}
476
484
  ${catChip}
477
485
  </div>
@@ -1095,6 +1103,7 @@
1095
1103
  return;
1096
1104
  }
1097
1105
  const shown = paginate(list, pagers.backlog);
1106
+ const pool = conflictPool();
1098
1107
  root.innerHTML = shown.map((t) => {
1099
1108
  const ver = t.version_id ? `<span class="task__tag">${escapeHtml(t.version_id)}</span>` : '';
1100
1109
  // task 1003828 — the row says WHOSE CRAFT and WHICH GOAL. A Ready card has
@@ -1115,7 +1124,7 @@
1115
1124
  return `
1116
1125
  <div class="history__row" data-task-id="${escapeHtml(String(t.id))}" title="Open task #${escapeHtml(String(t.id))}">
1117
1126
  <span class="history__id">#${escapeHtml(String(t.id))}</span>
1118
- <span class="history__title">${escapeHtml(t.title || '(untitled)').replace(/\//g, '/<wbr>')} ${ver} ${disciplineTag} ${rankTagHtml(t.requires_rank)}<br>${goalTag}</span>
1127
+ <span class="history__title">${escapeHtml(t.title || '(untitled)').replace(/\//g, '/<wbr>')} ${ver} ${disciplineTag} ${rankTagHtml(t.requires_rank)} ${conflictChipHtml(conflictPeers(t, pool))}<br>${goalTag}</span>
1119
1128
  <span class="history__when">${window.OTB.kindIsUnset(t.kind) ? '' : escapeHtml(t.kind)}</span>
1120
1129
  </div>`;
1121
1130
  }).join('');
@@ -27,6 +27,7 @@ const { unsatisfiedDeps } = require('./db-deps-criteria.js');
27
27
  // claim paths below use it — hand-building the object here is what made task
28
28
  // 1003107's merge pointer invisible on the live path (see its header).
29
29
  const { depRefusalRow } = require('./dead-deps.js');
30
+ const { siblingOverlaps, RESOLVED_STATUSES } = require('./goal-conflicts.js');
30
31
  const { claimGate, claimPermissionsFor, disambiguateWorktreeName, foldWorktreeName } = require('./db-rank-authz.js');
31
32
  const { sealSessionLog } = require('./db-shared.js');
32
33
  const { gatingRebaseTasksForBuilder, shouldRefuseClaimResolve } = require('./db-ship.js');
@@ -293,6 +294,32 @@ async function claimTask({ taskId, builderId, worktreeName, headSha, creatorSess
293
294
  overlapWarning = titles;
294
295
  }
295
296
 
297
+ // task 1001741: goal-aware proactive surfacing — also warn when an OPEN
298
+ // sibling in the same goal (not yet claimed) declares overlapping touches.
299
+ // Advisory only (ADR 0049); a failure here must never block the claim.
300
+ let siblingWarning = null;
301
+ if (task.goal_id != null) {
302
+ // SAVEPOINT-wrapped (the db-grade.js precedent): a failed statement inside
303
+ // an open pg transaction aborts the whole transaction, so without it the
304
+ // catch below would not make this check best-effort.
305
+ await client.query('SAVEPOINT sibling_overlap');
306
+ try {
307
+ const { rows: sibs } = await client.query(
308
+ `SELECT id, title, status, touches FROM tasks
309
+ WHERE goal_id = $1 AND id <> $2 AND cardinality(touches) > 0
310
+ AND NOT (status = ANY($3))`,
311
+ [task.goal_id, taskId, [...RESOLVED_STATUSES]]
312
+ );
313
+ const claimedIds = new Set(activeClaimRows.map((r) => String(r.task_id)));
314
+ const hits = siblingOverlaps(task, sibs, claimedIds);
315
+ await client.query('RELEASE SAVEPOINT sibling_overlap');
316
+ if (hits.length > 0) siblingWarning = hits;
317
+ } catch (err) {
318
+ await client.query('ROLLBACK TO SAVEPOINT sibling_overlap');
319
+ log.warn({ err: err.message, taskId }, '[claim] sibling-overlap check skipped');
320
+ }
321
+ }
322
+
296
323
  // Pre-allocate a migration number if the task signals it'll add one.
297
324
  // Trigger: the tasks.needs_migration boolean (migration 081, touches[]
298
325
  // cleanup [1/8] / task 876). Replaced the old touches[]-contains-'migrations/'
@@ -385,7 +412,7 @@ async function claimTask({ taskId, builderId, worktreeName, headSha, creatorSess
385
412
  console.log('[gds] graduation broadcast post-claim failed:', e && e.message);
386
413
  }
387
414
  }
388
- return { ...insertClaim.rows[0], just_unlocked: justUnlocked, overlap_warning: overlapWarning };
415
+ return { ...insertClaim.rows[0], just_unlocked: justUnlocked, overlap_warning: overlapWarning, sibling_overlap_warning: siblingWarning };
389
416
  } catch (err) {
390
417
  await client.query('ROLLBACK').catch(() => {});
391
418
  if (err && err.code === '23505') {
@@ -1223,7 +1223,7 @@ async function acceptMembershipRequestAndAddMember({ id, resolvedBy = null }, de
1223
1223
  // builder owns/manages so it doesn't drown an Archon in every goal's queue.
1224
1224
  async function listInboxFor(builderId, deps = {}) {
1225
1225
  const activePool = (deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : pool;
1226
- const [invitations, joinRequests, myPendingRequests] = await Promise.all([
1226
+ const [invitations, joinRequests, myPendingRequests, managedGoals] = await Promise.all([
1227
1227
  activePool.query(
1228
1228
  `SELECT r.id, r.goal_id, r.kind, r.status, r.note, r.created_at,
1229
1229
  g.title AS goal_title, g.version_id, g.visibility,
@@ -1259,11 +1259,25 @@ async function listInboxFor(builderId, deps = {}) {
1259
1259
  ORDER BY r.created_at, r.id`,
1260
1260
  [builderId]
1261
1261
  ),
1262
+ // task 1001741: the OPEN goals this builder owns or leads — the set whose
1263
+ // task conflicts they are told about (the route computes the conflicts; this
1264
+ // only names the goals, so no task data is read here).
1265
+ activePool.query(
1266
+ `SELECT g.id, g.title
1267
+ FROM goals g
1268
+ WHERE g.status = 'open'
1269
+ AND (g.created_by = $1
1270
+ OR EXISTS (SELECT 1 FROM goal_members gm
1271
+ WHERE gm.goal_id = g.id AND gm.builder_id = $1 AND gm.membership_kind = 'lead'))
1272
+ ORDER BY g.id`,
1273
+ [builderId]
1274
+ ),
1262
1275
  ]);
1263
1276
  return {
1264
1277
  invitations: invitations.rows,
1265
1278
  joinRequests: joinRequests.rows,
1266
1279
  myPendingRequests: myPendingRequests.rows,
1280
+ managedGoals: managedGoals.rows,
1267
1281
  };
1268
1282
  }
1269
1283
 
@@ -123,7 +123,64 @@ function computeGoalConflicts({ goalTasks = [], activeClaimTaskIds = new Set(),
123
123
  return out;
124
124
  }
125
125
 
126
+ // siblingOverlaps — the CLAIM-TIME view (task 1001741): which OPEN sibling tasks
127
+ // in the claimed task's own goal declare touches that overlap it. Advisory, like
128
+ // every touches[] signal (ADR 0049); it names the sibling and the paths so the
129
+ // claimer can self-serialize, and never refuses. Siblings already holding an
130
+ // active claim are excluded — the active-claim warning already reports those.
131
+ function siblingOverlaps(task, siblings = [], activeClaimTaskIds = new Set()) {
132
+ if (!isOpenWithTouches(task)) return [];
133
+ const out = [];
134
+ for (const s of siblings) {
135
+ if (!s || String(s.id) === String(task.id)) continue;
136
+ if (activeClaimTaskIds.has(String(s.id)) || !isOpenWithTouches(s)) continue;
137
+ const paths = overlappingPaths(task.touches, s.touches);
138
+ if (paths.length) out.push({ task_id: Number(s.id), title: s.title || '', overlapping_paths: paths });
139
+ }
140
+ return out.sort((a, b) => a.task_id - b.task_id);
141
+ }
142
+
143
+ // summarizeLiveConflicts — the owner/manager NOTIFICATION view (task 1001741):
144
+ // only pairs where live work is involved (severity medium|high — at least one
145
+ // side holds an active claim) are worth interrupting a person for; 'low' is a
146
+ // planning-time heads-up that stays on the goal's own page. Returns counts plus
147
+ // the single most urgent pair so the card can name something concrete.
148
+ function summarizeLiveConflicts(pairs = []) {
149
+ const live = pairs.filter((p) => p.severity === 'high' || p.severity === 'medium');
150
+ const high = live.filter((p) => p.severity === 'high').length;
151
+ const top = live[0] || null; // computeGoalConflicts sorts most-severe first
152
+ return {
153
+ count: live.length,
154
+ high,
155
+ medium: live.length - high,
156
+ top: top && { task_a: top.task_a, task_b: top.task_b, title_a: top.title_a, title_b: top.title_b },
157
+ };
158
+ }
159
+
160
+ // goalConflictSummaries — the inbox slice (task 1001741): per goal, the live
161
+ // conflict summary over its OWN tasks (cross-goal pairs stay on the goal page).
162
+ // `db` is the lifecycle db facade, passed in so this file stays import-free.
163
+ // Bounded on purpose: this runs on the Home boot read, so it looks at the first
164
+ // MAX_INBOX_GOALS managed goals only, all in parallel, never one goal after another.
165
+ const MAX_INBOX_GOALS = 10;
166
+ async function goalConflictSummaries(db, goals = []) {
167
+ const rows = await Promise.all(goals.slice(0, MAX_INBOX_GOALS).map(async (g) => {
168
+ const goalTasks = await db.listTasks({ goalId: g.id });
169
+ const claims = await db.listActiveClaimsForTasks(goalTasks.map((t) => t.id));
170
+ const summary = summarizeLiveConflicts(computeGoalConflicts({
171
+ goalTasks,
172
+ activeClaimTaskIds: new Set(claims.map((c) => String(c.task_id))),
173
+ }));
174
+ return summary.count > 0 ? { goal_id: Number(g.id), goal_title: g.title || '', ...summary } : null;
175
+ }));
176
+ return rows.filter(Boolean);
177
+ }
178
+
126
179
  module.exports = {
180
+ MAX_INBOX_GOALS,
181
+ goalConflictSummaries,
182
+ summarizeLiveConflicts,
183
+ siblingOverlaps,
127
184
  RESOLVED_STATUSES,
128
185
  isOpenWithTouches,
129
186
  normalizeEntry,
@@ -292,7 +292,7 @@ module.exports = function buildClaimsRouter() {
292
292
  creatorSessionId: resolveCreatorSessionId(bindSession, req.gdsSession?.session_id),
293
293
  enforceOverlap: enforceTouches === true,
294
294
  });
295
- const { just_unlocked: justUnlocked, overlap_warning: overlapWarning, ...claim } = claimResult;
295
+ const { just_unlocked: justUnlocked, overlap_warning: overlapWarning, sibling_overlap_warning: siblingOverlapWarning, ...claim } = claimResult;
296
296
  const task = await db.getTask(Number(taskId));
297
297
  // task 1168: keep the evergreen newcomer-chore shelf full. If this claim
298
298
  // took a chore instance, stamp a replacement NOW (before responding) so
@@ -326,7 +326,7 @@ module.exports = function buildClaimsRouter() {
326
326
  const p = rw && typeof rw.rewardConfig === 'function' ? rw.rewardConfig() : null;
327
327
  if (p && typeof p.mode === 'string') rewardMode = { mode: p.mode, perTaskCredit: p.perTaskCredit !== false };
328
328
  } catch (_) { /* display-only: a claim must never fail over this */ }
329
- res.status(201).json({ claim, task, just_unlocked: justUnlocked ?? [], overlap_warning: overlapWarning ?? null, reward: rewardMode });
329
+ res.status(201).json({ claim, task, just_unlocked: justUnlocked ?? [], overlap_warning: overlapWarning ?? null, sibling_overlap_warning: siblingOverlapWarning ?? null, reward: rewardMode });
330
330
  } catch (err) {
331
331
  if (err && err.code) {
332
332
  const status =
@@ -275,15 +275,15 @@ module.exports = function buildGoalsRouter() {
275
275
  // GET /goals/inbox — the caller's private-goals inbox (task 1952 / ADR 0112):
276
276
  // { invitations: invites addressed to me (accept/decline); joinRequests: pending
277
277
  // join-requests on goals I own or manage (approve/deny); myPendingRequests: my own
278
- // outstanding requests }. Powers the home-page inbox widget (task 1954) + the
279
- // goals page. Mounted BEFORE GET /goals/:id so the literal 'inbox' path is matched
280
- // as its own route, not captured as an id (the /goals/reorder precedent).
281
- // rank: any-builder — every builder reads their OWN inbox (db.listInboxFor scopes
282
- // by req.builder.id; a builder can't read another's).
278
+ // outstanding requests; conflicts: live file conflicts in goals I manage, task 1001741,
279
+ // best effort }. Powers the home inbox widget (task 1954) + the goals page. Mounted BEFORE
280
+ // GET /goals/:id so 'inbox' is not captured as an id (/goals/reorder precedent).
281
+ // rank: any-builder — every builder reads their OWN inbox (listInboxFor scopes by req.builder.id).
283
282
  router.get('/goals/inbox', auth.requireBuilder, async (req, res) => {
284
283
  try {
285
- const inbox = await db.listInboxFor(req.builder.id);
286
- return res.json(inbox);
284
+ const { managedGoals = [], ...inbox } = await db.listInboxFor(req.builder.id);
285
+ const conflicts = await goalConflicts.goalConflictSummaries(db, managedGoals).catch((cErr) => { log.error('[gds] inbox conflicts skipped', cErr); return []; });
286
+ return res.json({ ...inbox, conflicts });
287
287
  } catch (err) {
288
288
  log.error('[gds] GET /goals/inbox', err);
289
289
  return res.fail('inbox_failed', { status: 500, message: 'internal error' });
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.1002",
3
+ "version": "1.19.1004",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.1002",
9
+ "version": "1.19.1004",
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.1002",
3
+ "version": "1.19.1004",
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",
@@ -672,6 +672,15 @@ async function main() {
672
672
  console.log(`\n⚠ touches[] overlaps an active claim: ${r.data.overlap_warning.join(', ')}`);
673
673
  console.log(' Advisory only — your claim stands; git merge is the authoritative collision check.');
674
674
  }
675
+ // task 1001741: goal-aware — an unclaimed OPEN sibling in the same goal
676
+ // declares overlapping files. Same advisory posture as above.
677
+ if (Array.isArray(r.data.sibling_overlap_warning) && r.data.sibling_overlap_warning.length > 0) {
678
+ console.log('\n⚠ touches[] overlaps open sibling task(s) in this goal:');
679
+ for (const s of r.data.sibling_overlap_warning) {
680
+ console.log(` #${s.task_id} ${s.title} — ${s.overlapping_paths.join(', ')}`);
681
+ }
682
+ console.log(' Advisory only — a goal owner can sequence or rescope these from the goal page.');
683
+ }
675
684
  for (const ach of (r.data.just_unlocked || [])) {
676
685
  const bonus = ach.credit_bonus > 0 ? ` (+${ach.credit_bonus}c bonus)` : '';
677
686
  console.log(`✨ Achievement unlocked: ${ach.name}${bonus}`);
package/src/module-api.js CHANGED
@@ -71,7 +71,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
71
71
  // there. scripts/gds/bump-version.js still rewrites the literal below; it appends
72
72
  // the entry to that file. Look for a version's history there, not here.
73
73
  // ---------------------------------------------------------------------------
74
- const CORE_VERSION = '1.19.1002'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
74
+ const CORE_VERSION = '1.19.1004'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
75
75
 
76
76
  // A namespaced logger so a module's log lines are attributable + consistent.
77
77
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -31,6 +31,10 @@ const { validate, LIMITS } = require('../src/bongos/routes/_helpers.js');
31
31
  const {
32
32
  RESOLVED_STATUSES, isOpenWithTouches, normalizeEntry, overlappingPaths,
33
33
  severityFor, computeGoalConflicts,
34
+ siblingOverlaps,
35
+ summarizeLiveConflicts,
36
+ goalConflictSummaries,
37
+ MAX_INBOX_GOALS,
34
38
  } = require('../modules/lifecycle/goal-conflicts.js');
35
39
  const { authorizeGoalManagement, RESOLVE_ACTIONS } = require('../modules/lifecycle/routes/goals.js');
36
40
  const { listActiveClaimsForTasks, requestClaimRelease, rescopeTaskTouches } = require('../modules/lifecycle/db.js');
@@ -294,6 +298,90 @@ await test('rescope wall: the route runs goal-scope-check verbatim (source contr
294
298
  'the rescope branch re-runs the scope wall on the new touches');
295
299
  });
296
300
 
301
+ // =========================================================================
302
+ // 4b. claim-time sibling overlap (task 1001741)
303
+ // =========================================================================
304
+
305
+ await test('siblingOverlaps: names open unclaimed siblings with overlapping touches, sorted', () => {
306
+ const me = { id: 1, status: 'ready', touches: ['src/bongos/'] };
307
+ const sibs = [
308
+ { id: 9, title: 'b', status: 'ready', touches: ['src/bongos/db.js'] },
309
+ { id: 3, title: 'a', status: 'backlog', touches: ['src/bongos/*'] },
310
+ { id: 4, title: 'far', status: 'ready', touches: ['docs/'] },
311
+ ];
312
+ const out = siblingOverlaps(me, sibs);
313
+ assert.deepEqual(out.map((x) => x.task_id), [3, 9]);
314
+ assert.deepEqual(out[1].overlapping_paths, ['src/bongos/db.js']);
315
+ });
316
+
317
+ await test('siblingOverlaps: skips self, resolved, claimed, and touch-less siblings', () => {
318
+ const me = { id: 1, status: 'ready', touches: ['a/'] };
319
+ const sibs = [
320
+ { id: 1, title: 'self', status: 'ready', touches: ['a/'] },
321
+ { id: 2, title: 'done', status: 'shipped', touches: ['a/'] },
322
+ { id: 3, title: 'held', status: 'active', touches: ['a/'] },
323
+ { id: 4, title: 'none', status: 'ready', touches: [] },
324
+ ];
325
+ assert.deepEqual(siblingOverlaps(me, sibs, new Set(['3'])), []);
326
+ assert.deepEqual(siblingOverlaps({ id: 1, touches: [] }, sibs), []);
327
+ });
328
+
329
+ await test('summarizeLiveConflicts: only medium/high pairs notify; low stays on the goal page', () => {
330
+ const pairs = [
331
+ { severity: 'high', task_a: 1, task_b: 2, title_a: 'A', title_b: 'B' },
332
+ { severity: 'medium', task_a: 3, task_b: 4, title_a: 'C', title_b: 'D' },
333
+ { severity: 'low', task_a: 5, task_b: 6, title_a: 'E', title_b: 'F' },
334
+ ];
335
+ const out = summarizeLiveConflicts(pairs);
336
+ assert.equal(out.count, 2);
337
+ assert.equal(out.high, 1);
338
+ assert.equal(out.medium, 1);
339
+ assert.deepEqual(out.top, { task_a: 1, task_b: 2, title_a: 'A', title_b: 'B' });
340
+ assert.deepEqual(summarizeLiveConflicts([pairs[2]]), { count: 0, high: 0, medium: 0, top: null });
341
+ assert.equal(summarizeLiveConflicts().count, 0);
342
+ });
343
+
344
+ await test('goalConflictSummaries: per managed goal, live pairs only, capped and parallel', async () => {
345
+ const byGoal = {
346
+ 1: [task(10, ['a/'], 'active'), task(11, ['a/x.js'], 'ready')], // medium pair → reported
347
+ 2: [task(20, ['b/'], 'ready'), task(21, ['b/y.js'], 'ready')], // low pair → not reported
348
+ };
349
+ const db = {
350
+ listTasks: async ({ goalId }) => byGoal[goalId] || [],
351
+ listActiveClaimsForTasks: async (ids) => ids.filter((id) => id === 10).map((id) => ({ task_id: id })),
352
+ };
353
+ const out = await goalConflictSummaries(db, [{ id: 1, title: 'One' }, { id: 2, title: 'Two' }]);
354
+ assert.equal(out.length, 1);
355
+ assert.equal(out[0].goal_id, 1);
356
+ assert.equal(out[0].goal_title, 'One');
357
+ assert.equal(out[0].count, 1);
358
+ assert.equal(out[0].medium, 1);
359
+
360
+ let asked = 0;
361
+ const counting = { listTasks: async () => { asked++; return []; }, listActiveClaimsForTasks: async () => [] };
362
+ const many = Array.from({ length: MAX_INBOX_GOALS + 7 }, (_, i) => ({ id: i + 1, title: 'g' }));
363
+ await goalConflictSummaries(counting, many);
364
+ assert.equal(asked, MAX_INBOX_GOALS, 'a builder leading many goals cannot make Home boot unbounded');
365
+ });
366
+
367
+ await test('inbox card: cleanTitle really strips dashes (regex keeps its \s, not a literal s)', async () => {
368
+ const fs = await import('node:fs');
369
+ const src = fs.readFileSync(new URL('../modules/hall-ui/public/goal-inbox.js', import.meta.url), 'utf8');
370
+ const line = src.split('\n').find((l) => l.includes('const cleanTitle ='));
371
+ assert.ok(line, 'cleanTitle exists');
372
+ const clean = new Function('return ' + line.split('const cleanTitle =')[1].trim().replace(/;$/, ''))();
373
+ assert.equal(clean('Fix A — B'), 'Fix A, B');
374
+ assert.equal(clean('tools—stuff'), 'tools, stuff', 'no adjacent letter is eaten');
375
+ });
376
+
377
+ await test('inbox route: conflicts ride GET /goals/inbox for managed goals, best-effort', async () => {
378
+ const fs = await import('node:fs');
379
+ const src = fs.readFileSync(new URL('../modules/lifecycle/routes/goals.js', import.meta.url), 'utf8');
380
+ const inboxBranch = src.split("router.get('/goals/inbox'")[1].split("router.get('/goals/:id'")[0];
381
+ assert.match(inboxBranch, /goalConflicts\.goalConflictSummaries\(db, managedGoals\)/);
382
+ assert.match(inboxBranch, /\.catch\(\(cErr\)/, 'a conflict failure must not take the inbox down');
383
+ });
384
+
297
385
  // =========================================================================
298
386
  // 5. the route-rank pin
299
387
  // =========================================================================
@@ -163,7 +163,7 @@ function bootWorkBoard({ signedIn = true, goalsFail = false } = {}) {
163
163
  OTBKit: kit,
164
164
  OTBTaskDetail: { href: (id) => `/task/${id}`, open() {} },
165
165
  OTBTaskLib: {
166
- priorityRank: () => 0, touchesConflict: () => false, rankTagHtml: () => '',
166
+ priorityRank: () => 0, touchesConflict: () => false, conflictPeers: () => [], conflictChipHtml: () => '', rankTagHtml: () => '',
167
167
  creditsPerMinute: () => 0, buildPromptForTask: () => '', friendlyBatchReason: () => '',
168
168
  taskActivityMs: () => 0, STALE_CHOICES: [{ value: '', label: '— any —' }], isStale: () => false,
169
169
  },