@bongos/core 1.19.1003 → 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.
- package/.bongos-core.json +33 -28
- package/clients/bongos-client/index.d.ts +1 -1
- package/docs/api/openapi.json +3 -1
- package/docs/copy-inventory.md +66 -58
- package/docs/copy-registry.json +154 -72
- package/docs/module-api-changelog.md +3 -1
- package/modules/hall-ui/public/goal-inbox.js +41 -0
- package/modules/hall-ui/public/style.css +9 -0
- package/modules/hall-ui/public/task-lib.js +21 -1
- package/modules/hall-ui/public/work.js +11 -2
- package/modules/lifecycle/db-claims.js +28 -1
- package/modules/lifecycle/db-goals.js +15 -1
- package/modules/lifecycle/goal-conflicts.js +57 -0
- package/modules/lifecycle/routes/claims.js +2 -2
- package/modules/lifecycle/routes/goals.js +7 -7
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/claim.js +9 -0
- package/src/module-api.js +1 -1
- package/tests/goal_conflicts.mjs +88 -0
- package/tests/hall_mine_lens.mjs +1 -1
- package/tests/ideator_traceable_proof.mjs +315 -0
- package/tests/task_lib.mjs +25 -0
|
@@ -2479,6 +2479,8 @@ is load-bearing: the script throws rather than guess if it is missing, and
|
|
|
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
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
|
|
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)
|
|
2483
2485
|
---------------------------------------------------------------------------
|
|
2484
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
|
|
279
|
-
//
|
|
280
|
-
//
|
|
281
|
-
// rank: any-builder — every builder reads their OWN inbox (
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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",
|
package/scripts/gds/claim.js
CHANGED
|
@@ -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.
|
|
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');
|
package/tests/goal_conflicts.mjs
CHANGED
|
@@ -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
|
// =========================================================================
|
package/tests/hall_mine_lens.mjs
CHANGED
|
@@ -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
|
},
|