@bongos/core 1.20.46 → 1.20.47
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 +41 -31
- package/docs/copy-inventory.md +151 -148
- package/docs/copy-registry.json +266 -199
- package/docs/module-api-changelog.md +2 -0
- package/docs/page-inventory.json +2 -1
- package/docs/page-readings.json +90 -90
- package/modules/hall-ui/public/bulk-toolbar.js +291 -0
- package/modules/hall-ui/public/goals-page.js +29 -44
- package/modules/hall-ui/public/goals-render.js +33 -21
- package/modules/hall-ui/public/goals.html +12 -16
- package/modules/hall-ui/public/style.css +74 -3
- package/modules/hall-ui/public/work.css +4 -66
- package/modules/hall-ui/public/work.html +2 -1
- package/modules/hall-ui/public/work.js +17 -236
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +6 -0
- package/src/module-api.js +1 -1
- package/tests/bulk_toolbar.mjs +202 -0
- package/tests/claim_action.mjs +13 -4
- package/tests/goal_category_ui.mjs +1 -0
- package/tests/goal_criterion_confirm_ui.mjs +2 -0
- package/tests/goal_forest_page.mjs +1 -0
- package/tests/goal_invite_consent.mjs +1 -1
- package/tests/goal_mine_lens.mjs +1 -0
- package/tests/hall_board_world.mjs +1 -1
- package/tests/hall_mine_lens.mjs +1 -0
|
@@ -14,7 +14,9 @@
|
|
|
14
14
|
verification state, which DESIGN.md §Reuse names as the one sanctioned
|
|
15
15
|
exception to the Fifteen Rule.
|
|
16
16
|
|
|
17
|
-
goals.html LOADS THIS SHEET
|
|
17
|
+
goals.html NO LONGER LOADS THIS SHEET (task 1003141): what the goal board's
|
|
18
|
+
cards borrowed from it — .claim-count-line, .wip__release, .wip__select — now
|
|
19
|
+
lives in style.css, so this sheet is the board's alone. */
|
|
18
20
|
|
|
19
21
|
/* `.page-head--board` MOVED to style.css (task 1003305) when the ideas composer
|
|
20
22
|
became its third consumer: a modifier on the shell's own .page-head is shell
|
|
@@ -35,17 +37,6 @@
|
|
|
35
37
|
.wip-builder { color: var(--ink); font-weight: 500; }
|
|
36
38
|
.wip-builder small { color: var(--ink-soft); font-weight: 400; }
|
|
37
39
|
|
|
38
|
-
/* Filtered-count line under the filter bar — the label voice, with the number
|
|
39
|
-
itself stepping up to ink so the count reads before the words. */
|
|
40
|
-
.claim-count-line {
|
|
41
|
-
margin: 0 0 12px;
|
|
42
|
-
font-family: var(--label-face);
|
|
43
|
-
font-size: var(--label-size);
|
|
44
|
-
letter-spacing: var(--label-space);
|
|
45
|
-
text-transform: var(--label-case);
|
|
46
|
-
color: var(--ink-faint);
|
|
47
|
-
}
|
|
48
|
-
|
|
49
40
|
.backlog-count {
|
|
50
41
|
font-family: var(--font-mono);
|
|
51
42
|
font-size: 11px;
|
|
@@ -67,27 +58,6 @@
|
|
|
67
58
|
|
|
68
59
|
/* In-flight table: release action on the viewer's own claims. */
|
|
69
60
|
.wip__action { text-align: right; white-space: nowrap; }
|
|
70
|
-
.wip__release {
|
|
71
|
-
display: inline-flex;
|
|
72
|
-
align-items: center;
|
|
73
|
-
justify-content: center;
|
|
74
|
-
min-height: 30px;
|
|
75
|
-
padding: 0 14px;
|
|
76
|
-
background: none;
|
|
77
|
-
border: var(--line) solid var(--rule);
|
|
78
|
-
color: var(--ink-soft);
|
|
79
|
-
border-radius: var(--radius-pill);
|
|
80
|
-
font-size: 13px;
|
|
81
|
-
text-transform: lowercase;
|
|
82
|
-
cursor: pointer;
|
|
83
|
-
transition: background 150ms, color 150ms, border-color 150ms;
|
|
84
|
-
}
|
|
85
|
-
.wip__release:hover:not(:disabled) {
|
|
86
|
-
background: var(--danger-weak);
|
|
87
|
-
color: var(--danger);
|
|
88
|
-
border-color: color-mix(in srgb, var(--danger) 40%, transparent);
|
|
89
|
-
}
|
|
90
|
-
.wip__release:disabled { opacity: 0.55; cursor: default; }
|
|
91
61
|
.wip__mine-none { color: var(--ink-faint); }
|
|
92
62
|
.wip__row--mine { background: var(--accent-tint); }
|
|
93
63
|
|
|
@@ -121,7 +91,7 @@
|
|
|
121
91
|
.history__when { color: var(--ink-soft); font-size: 12.5px; white-space: nowrap; font-variant-numeric: tabular-nums; }
|
|
122
92
|
|
|
123
93
|
|
|
124
|
-
/* ---- task 1759: multi-select
|
|
94
|
+
/* ---- task 1759: multi-select (the toolbar AND the checkbox are in style.css) ----
|
|
125
95
|
Re-cut for the 24px floor and the world (task 1003304). The floating dark
|
|
126
96
|
pill is gone: it painted an INVERTED surface (background: var(--ink)) with
|
|
127
97
|
three rgba(255,255,255,…) literals on top, which was correct only for as long
|
|
@@ -130,38 +100,6 @@
|
|
|
130
100
|
content column instead, inset by the rail, so it never has to invert. */
|
|
131
101
|
|
|
132
102
|
.wip__select-cell { width: 1%; text-align: center; }
|
|
133
|
-
.wip__select {
|
|
134
|
-
appearance: none;
|
|
135
|
-
-webkit-appearance: none;
|
|
136
|
-
position: relative;
|
|
137
|
-
width: 24px;
|
|
138
|
-
height: 24px;
|
|
139
|
-
margin: 0;
|
|
140
|
-
cursor: pointer;
|
|
141
|
-
}
|
|
142
|
-
.wip__select::before {
|
|
143
|
-
content: "";
|
|
144
|
-
position: absolute;
|
|
145
|
-
left: 5px;
|
|
146
|
-
top: 5px;
|
|
147
|
-
width: 14px;
|
|
148
|
-
height: 14px;
|
|
149
|
-
border: var(--line) solid var(--rule);
|
|
150
|
-
border-radius: 3px;
|
|
151
|
-
}
|
|
152
|
-
.wip__select:checked::before { background: var(--accent); border-color: var(--accent); }
|
|
153
|
-
.wip__select:checked::after {
|
|
154
|
-
content: "";
|
|
155
|
-
position: absolute;
|
|
156
|
-
left: 9px;
|
|
157
|
-
top: 8px;
|
|
158
|
-
width: 4px;
|
|
159
|
-
height: 8px;
|
|
160
|
-
border: solid var(--accent-on);
|
|
161
|
-
border-width: 0 1.5px 1.5px 0;
|
|
162
|
-
transform: rotate(42deg);
|
|
163
|
-
}
|
|
164
|
-
|
|
165
103
|
/* ---- "Only mine" lens (the board's tab-wide filter; goal board's To-do bar
|
|
166
104
|
borrows the same class) --------------------------------------------------
|
|
167
105
|
Deliberately QUIETER than the filter bar below it: a checkbox in the label
|
|
@@ -189,7 +189,8 @@
|
|
|
189
189
|
<script src="/builders/work-summary.js" defer></script>
|
|
190
190
|
<script src="/builders/task-detail.js?v=2026-07-03-taskdetail" defer></script>
|
|
191
191
|
<script src="/builders/brand-apply.js?v=2026-06-19-r56" defer></script>
|
|
192
|
-
<script src="/builders/
|
|
192
|
+
<script src="/builders/bulk-toolbar.js" defer></script>
|
|
193
|
+
<script src="/builders/work.js?v=2026-09-30-bulktoolbar" defer></script>
|
|
193
194
|
<script src="/builders/copy-on-click.js?v=2026-06-01-copy-1" defer></script>
|
|
194
195
|
</body>
|
|
195
196
|
</html>
|
|
@@ -187,9 +187,9 @@
|
|
|
187
187
|
// The hall's ONE claim/release action (task 1003458). Everything this board
|
|
188
188
|
// used to hand-roll four times — the POST, the refusal sentence, the
|
|
189
189
|
// stale-view refresh, the all-or-nothing batch report — lives in
|
|
190
|
-
// claim-action.js now; doClaim/doRelease
|
|
191
|
-
//
|
|
192
|
-
//
|
|
190
|
+
// claim-action.js now; doClaim/doRelease below keep only what is
|
|
191
|
+
// genuinely this page: button text and the re-entrancy guard (the bulk
|
|
192
|
+
// toolbar is bulk-toolbar.js, built from this same action). friendlyBatchReason is injected rather
|
|
193
193
|
// than imported there, so claim-action.js stays dependency-free.
|
|
194
194
|
// refreshLive is a hoisted declaration; toast and API are already bound above.
|
|
195
195
|
const claimAction = makeClaimAction({
|
|
@@ -291,18 +291,6 @@
|
|
|
291
291
|
// String(claim_id) -> task_id for the viewer's OWN in-flight claims (release
|
|
292
292
|
// is only ever offered on those rows to begin with — see renderInFlight).
|
|
293
293
|
const selection = { claim: new Set(), release: new Map() };
|
|
294
|
-
// Last server dry-run (POST /claims/batch/validate) result, keyed by the
|
|
295
|
-
// sorted selected-id set so an unrelated re-render doesn't refire it.
|
|
296
|
-
const validateCache = { key: '', parallelSafe: null, byId: new Map() };
|
|
297
|
-
let validateDebounce = null;
|
|
298
|
-
// Per-task dependency edges for the "can these be sequenced?" cycle check —
|
|
299
|
-
// fetched lazily via GET /tasks/:id (task-detail.js's call) since the
|
|
300
|
-
// claimable feed doesn't carry dependencies[] per row. Stable for a ready
|
|
301
|
-
// task within one page session, so caching by id is safe.
|
|
302
|
-
const depsCache = new Map(); // task_id -> [dependency task_id, ...]
|
|
303
|
-
const sequentialCache = { key: '', order: null };
|
|
304
|
-
let sequentialDebounce = null;
|
|
305
|
-
|
|
306
294
|
// task 1756: two tasks can't run in parallel if their touches[] overlap — exact
|
|
307
295
|
// path match or a directory-prefix either way (mirrors the server's touchesOverlap
|
|
308
296
|
// / the goals-page conflict estimate). Empty touches never conflict.
|
|
@@ -700,207 +688,18 @@
|
|
|
700
688
|
}
|
|
701
689
|
}
|
|
702
690
|
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
validateCache.key = key;
|
|
716
|
-
validateCache.parallelSafe = out.parallelSafe;
|
|
717
|
-
validateCache.byId = out.byId;
|
|
718
|
-
renderBulkToolbar();
|
|
719
|
-
}, 350);
|
|
720
|
-
}
|
|
721
|
-
|
|
722
|
-
async function fetchDeps(taskId) {
|
|
723
|
-
if (depsCache.has(taskId)) return depsCache.get(taskId);
|
|
724
|
-
let deps = [];
|
|
725
|
-
try {
|
|
726
|
-
const d = await getJSON(`${API}/tasks/${encodeURIComponent(taskId)}`, { softAuth: true });
|
|
727
|
-
if (d && d.task && Array.isArray(d.task.dependencies)) {
|
|
728
|
-
deps = d.task.dependencies.map((x) => Number(x.id)).filter((x) => Number.isFinite(x));
|
|
729
|
-
}
|
|
730
|
-
} catch (_) { /* treat as no known dependencies */ }
|
|
731
|
-
depsCache.set(taskId, deps);
|
|
732
|
-
return deps;
|
|
733
|
-
}
|
|
734
|
-
|
|
735
|
-
// Kahn's algorithm restricted to edges BETWEEN the selected ids (an
|
|
736
|
-
// out-of-selection dependency doesn't block sequencing the selection
|
|
737
|
-
// itself). Returns the topological order, or null if the restricted graph
|
|
738
|
-
// has a cycle (can't be sequenced).
|
|
739
|
-
function topoOrder(ids, edgesByTask) {
|
|
740
|
-
const remaining = new Set(ids);
|
|
741
|
-
const order = [];
|
|
742
|
-
let progressed = true;
|
|
743
|
-
while (remaining.size && progressed) {
|
|
744
|
-
progressed = false;
|
|
745
|
-
for (const id of Array.from(remaining)) {
|
|
746
|
-
const deps = (edgesByTask.get(id) || []).filter((d) => remaining.has(d));
|
|
747
|
-
if (deps.length === 0) {
|
|
748
|
-
order.push(id);
|
|
749
|
-
remaining.delete(id);
|
|
750
|
-
progressed = true;
|
|
751
|
-
}
|
|
752
|
-
}
|
|
753
|
-
}
|
|
754
|
-
return remaining.size ? null : order;
|
|
755
|
-
}
|
|
756
|
-
|
|
757
|
-
function scheduleSequentialCheck(key, ids) {
|
|
758
|
-
if (sequentialDebounce) clearTimeout(sequentialDebounce);
|
|
759
|
-
sequentialDebounce = setTimeout(async () => {
|
|
760
|
-
const edgesByTask = new Map();
|
|
761
|
-
await Promise.all(ids.map(async (id) => { edgesByTask.set(id, await fetchDeps(id)); }));
|
|
762
|
-
sequentialCache.key = key;
|
|
763
|
-
sequentialCache.order = topoOrder(ids, edgesByTask);
|
|
764
|
-
renderBulkToolbar();
|
|
765
|
-
}, 350);
|
|
766
|
-
}
|
|
767
|
-
|
|
768
|
-
function renderBulkToolbar() {
|
|
769
|
-
const bar = $('#bulk-toolbar');
|
|
770
|
-
if (!bar) return;
|
|
771
|
-
if (selectionTotal() === 0) { bar.hidden = true; return; }
|
|
772
|
-
bar.hidden = false;
|
|
773
|
-
|
|
774
|
-
const countEl = $('#bulk-count');
|
|
775
|
-
if (countEl) {
|
|
776
|
-
const parts = [];
|
|
777
|
-
if (selection.claim.size) parts.push(`${selection.claim.size} to claim`);
|
|
778
|
-
if (selection.release.size) parts.push(`${selection.release.size} to release`);
|
|
779
|
-
countEl.textContent = parts.join(' · ');
|
|
780
|
-
}
|
|
781
|
-
|
|
782
|
-
const claimBtn = $('#bulk-claim-btn');
|
|
783
|
-
if (claimBtn) {
|
|
784
|
-
claimBtn.disabled = selection.claim.size === 0;
|
|
785
|
-
claimBtn.textContent = selection.claim.size > 1 ? `Bulk claim (${selection.claim.size})` : 'Bulk claim';
|
|
786
|
-
}
|
|
787
|
-
const releaseBtn = $('#bulk-release-btn');
|
|
788
|
-
if (releaseBtn) {
|
|
789
|
-
releaseBtn.disabled = selection.release.size === 0;
|
|
790
|
-
releaseBtn.textContent = selection.release.size > 1 ? `Bulk release (${selection.release.size})` : 'Bulk release';
|
|
791
|
-
}
|
|
792
|
-
|
|
793
|
-
const parallelBtn = $('#bulk-parallel-btn');
|
|
794
|
-
if (parallelBtn) {
|
|
795
|
-
if (selection.claim.size < 2) {
|
|
796
|
-
parallelBtn.disabled = true;
|
|
797
|
-
parallelBtn.title = 'Select at least 2 waiting tasks to copy a parallel-session prompt.';
|
|
798
|
-
} else {
|
|
799
|
-
const ids = selectedClaimIds();
|
|
800
|
-
const key = selectionKey(ids);
|
|
801
|
-
if (validateCache.key === key && validateCache.parallelSafe != null) {
|
|
802
|
-
parallelBtn.disabled = !validateCache.parallelSafe;
|
|
803
|
-
if (!validateCache.parallelSafe) {
|
|
804
|
-
const bad = [...validateCache.byId.values()].filter((r) => !r.valid);
|
|
805
|
-
const detail = bad.map((r) => `#${r.task_id}: ${(r.reasons || []).map(friendlyBatchReason).join(', ')}`).join(' · ');
|
|
806
|
-
parallelBtn.title = detail || 'Selected tasks conflict and cannot run in parallel.';
|
|
807
|
-
} else {
|
|
808
|
-
parallelBtn.title = '';
|
|
809
|
-
}
|
|
810
|
-
} else {
|
|
811
|
-
parallelBtn.disabled = true;
|
|
812
|
-
parallelBtn.title = 'Checking for conflicts…';
|
|
813
|
-
scheduleValidate(key, ids);
|
|
814
|
-
}
|
|
815
|
-
}
|
|
816
|
-
}
|
|
817
|
-
|
|
818
|
-
const sequentialBtn = $('#bulk-sequential-btn');
|
|
819
|
-
if (sequentialBtn) {
|
|
820
|
-
if (selection.claim.size < 2) {
|
|
821
|
-
sequentialBtn.disabled = true;
|
|
822
|
-
sequentialBtn.title = 'Select at least 2 waiting tasks to copy a sequential prompt.';
|
|
823
|
-
} else {
|
|
824
|
-
const ids = selectedClaimIds();
|
|
825
|
-
const key = selectionKey(ids);
|
|
826
|
-
if (sequentialCache.key === key) {
|
|
827
|
-
sequentialBtn.disabled = sequentialCache.order == null;
|
|
828
|
-
sequentialBtn.title = sequentialCache.order == null
|
|
829
|
-
? 'These tasks depend on each other in a cycle and cannot be ordered.'
|
|
830
|
-
: '';
|
|
831
|
-
} else {
|
|
832
|
-
sequentialBtn.disabled = true;
|
|
833
|
-
sequentialBtn.title = 'Checking dependency order…';
|
|
834
|
-
scheduleSequentialCheck(key, ids);
|
|
835
|
-
}
|
|
836
|
-
}
|
|
837
|
-
}
|
|
838
|
-
}
|
|
839
|
-
|
|
840
|
-
function clearSelection() {
|
|
841
|
-
selection.claim.clear();
|
|
842
|
-
selection.release.clear();
|
|
843
|
-
renderClaimable();
|
|
844
|
-
renderInFlight();
|
|
845
|
-
renderBulkToolbar();
|
|
846
|
-
}
|
|
847
|
-
|
|
848
|
-
function buildBulkParallelPrompt(tasks) {
|
|
849
|
-
const hallBase = location.hostname.startsWith('builders.') ? location.origin : `${location.origin}/builders`;
|
|
850
|
-
const lines = [
|
|
851
|
-
`I want to work on ${tasks.length} Bongos tasks IN PARALLEL — open ${tasks.length} separate Claude Code sessions, each in its own worktree, one per task below, so they can run at the same time without touching each other's files.`,
|
|
852
|
-
'',
|
|
853
|
-
];
|
|
854
|
-
tasks.forEach((t, i) => {
|
|
855
|
-
lines.push(`Session ${i + 1} — task ${t.id} (${t.title}) — ${hallBase}#/task/${t.id}`);
|
|
856
|
-
lines.push(`Claim it with /builder-claim ${t.id}, follow its acceptance criteria, stay within touches[], ship via /builder-ship when done.`);
|
|
857
|
-
lines.push('');
|
|
858
|
-
});
|
|
859
|
-
return lines.join('\n').trim();
|
|
860
|
-
}
|
|
861
|
-
|
|
862
|
-
function buildBulkSequentialPrompt(tasks) {
|
|
863
|
-
const hallBase = location.hostname.startsWith('builders.') ? location.origin : `${location.origin}/builders`;
|
|
864
|
-
const lines = [
|
|
865
|
-
`I want to work on ${tasks.length} Bongos tasks IN ORDER, one after another in this same session — claim, build, and ship each before moving to the next.`,
|
|
866
|
-
'',
|
|
867
|
-
];
|
|
868
|
-
tasks.forEach((t, i) => {
|
|
869
|
-
lines.push(`${i + 1}. Task ${t.id} (${t.title}) — ${hallBase}#/task/${t.id}`);
|
|
870
|
-
});
|
|
871
|
-
lines.push('');
|
|
872
|
-
lines.push('For each: /builder-claim <id>, follow its acceptance criteria, stay within touches[], /builder-ship <id> before starting the next.');
|
|
873
|
-
return lines.join('\n');
|
|
874
|
-
}
|
|
875
|
-
|
|
876
|
-
async function doBulkClaim() {
|
|
877
|
-
const ids = selectedClaimIds();
|
|
878
|
-
if (!ids.length) return;
|
|
879
|
-
const btn = $('#bulk-claim-btn');
|
|
880
|
-
if (btn) { btn.disabled = true; btn.textContent = 'Claiming…'; }
|
|
881
|
-
try {
|
|
882
|
-
const out = await claimAction.claimMany(ids);
|
|
883
|
-
if (out.ok) selection.claim.clear();
|
|
884
|
-
} finally {
|
|
885
|
-
renderBulkToolbar();
|
|
886
|
-
}
|
|
887
|
-
}
|
|
888
|
-
|
|
889
|
-
async function doBulkRelease() {
|
|
890
|
-
const entries = [...selection.release.entries()]; // [claimId, taskId]
|
|
891
|
-
if (!entries.length) return;
|
|
892
|
-
const label = entries.length === 1 ? `#${entries[0][1]}` : `${entries.length} tasks`;
|
|
893
|
-
const pronoun = entries.length === 1 ? 'It returns' : 'They return';
|
|
894
|
-
if (!window.confirm(`Release ${label}? ${pronoun} to the queue and no ${curLabel()} are earned.`)) return;
|
|
895
|
-
const btn = $('#bulk-release-btn');
|
|
896
|
-
if (btn) { btn.disabled = true; btn.textContent = 'Releasing…'; }
|
|
897
|
-
try {
|
|
898
|
-
const out = await claimAction.releaseMany(entries);
|
|
899
|
-
if (out.ok) selection.release.clear();
|
|
900
|
-
} finally {
|
|
901
|
-
renderBulkToolbar();
|
|
902
|
-
}
|
|
903
|
-
}
|
|
691
|
+
// The toolbar itself (render, bulk claim/release, the two prompts) is the
|
|
692
|
+
// hall's shared one — bulk-toolbar.js. This page keeps the selection and how
|
|
693
|
+
// Clear repaints its own cards.
|
|
694
|
+
const bulk = window.OTBBulkToolbar.makeBulkToolbar({
|
|
695
|
+
ids: { bar: 'bulk-toolbar', count: 'bulk-count', claim: 'bulk-claim-btn', release: 'bulk-release-btn', parallel: 'bulk-parallel-btn', sequential: 'bulk-sequential-btn', clear: 'bulk-clear-btn' },
|
|
696
|
+
selection,
|
|
697
|
+
findTask: (id) => taskState.all.find((t) => Number(t.id) === id),
|
|
698
|
+
claimAction, getJSON, API, copyText, toast, curLabel, describeReason: friendlyBatchReason,
|
|
699
|
+
onClear: () => { renderClaimable(); renderInFlight(); },
|
|
700
|
+
});
|
|
701
|
+
function renderBulkToolbar() { bulk.render(); }
|
|
702
|
+
function clearSelection() { bulk.clearSelection(); }
|
|
904
703
|
|
|
905
704
|
async function doClaim(taskId, btn) {
|
|
906
705
|
const key = `claim:${taskId}`;
|
|
@@ -1373,26 +1172,8 @@
|
|
|
1373
1172
|
doRelease(rel.dataset.claimId, rel.dataset.taskId, rel);
|
|
1374
1173
|
});
|
|
1375
1174
|
|
|
1376
|
-
// task 1759: the bottom bulk-action toolbar.
|
|
1377
|
-
|
|
1378
|
-
$('#bulk-release-btn')?.addEventListener('click', doBulkRelease);
|
|
1379
|
-
$('#bulk-clear-btn')?.addEventListener('click', clearSelection);
|
|
1380
|
-
$('#bulk-parallel-btn')?.addEventListener('click', async () => {
|
|
1381
|
-
const ids = selectedClaimIds();
|
|
1382
|
-
const tasks = ids.map((id) => taskState.all.find((t) => Number(t.id) === id)).filter(Boolean);
|
|
1383
|
-
if (tasks.length < 2) return;
|
|
1384
|
-
const ok = await copyText(buildBulkParallelPrompt(tasks));
|
|
1385
|
-
toast(ok ? `Parallel-session prompt for ${tasks.length} tasks copied.` : 'Could not copy to the clipboard.');
|
|
1386
|
-
});
|
|
1387
|
-
$('#bulk-sequential-btn')?.addEventListener('click', async () => {
|
|
1388
|
-
const ids = selectedClaimIds();
|
|
1389
|
-
const key = selectionKey(ids);
|
|
1390
|
-
if (sequentialCache.key !== key || sequentialCache.order == null) return;
|
|
1391
|
-
const tasks = sequentialCache.order.map((id) => taskState.all.find((t) => Number(t.id) === id)).filter(Boolean);
|
|
1392
|
-
if (tasks.length < 2) return;
|
|
1393
|
-
const ok = await copyText(buildBulkSequentialPrompt(tasks));
|
|
1394
|
-
toast(ok ? `Sequential prompt for ${tasks.length} tasks copied.` : 'Could not copy to the clipboard.');
|
|
1395
|
-
});
|
|
1175
|
+
// task 1759: the bottom bulk-action toolbar (shared: bulk-toolbar.js).
|
|
1176
|
+
bulk.wire();
|
|
1396
1177
|
}
|
|
1397
1178
|
|
|
1398
1179
|
document.addEventListener('DOMContentLoaded', () => {
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.20.
|
|
3
|
+
"version": "1.20.47",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.20.
|
|
9
|
+
"version": "1.20.47",
|
|
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.20.
|
|
3
|
+
"version": "1.20.47",
|
|
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/release-notes.json
CHANGED
|
@@ -8389,5 +8389,11 @@
|
|
|
8389
8389
|
"id": "1004366",
|
|
8390
8390
|
"text": "Every store module version now has a readable how-to page in the hall, with a Save as PDF button and an optional link to a Claude page."
|
|
8391
8391
|
}
|
|
8392
|
+
],
|
|
8393
|
+
"1.20.47": [
|
|
8394
|
+
{
|
|
8395
|
+
"id": "1003141",
|
|
8396
|
+
"text": "The Goals page now has the same bulk actions as the Tasks board: claim several tasks, release your own, or copy a prompt to work them in parallel or in order. Both pages now share one toolbar, so they stay in step."
|
|
8397
|
+
}
|
|
8392
8398
|
]
|
|
8393
8399
|
}
|
package/src/module-api.js
CHANGED
|
@@ -75,7 +75,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
|
|
|
75
75
|
// MAJOR (see allowBoxScope below): passes the request through untouched.
|
|
76
76
|
function deprecatedNoopMiddleware(_req, _res, next) { next(); }
|
|
77
77
|
|
|
78
|
-
const CORE_VERSION = '1.20.
|
|
78
|
+
const CORE_VERSION = '1.20.47'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
79
79
|
|
|
80
80
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
81
81
|
// Usage: const log = api.logger('discord'); log.info('mounted');
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
// tests/bulk_toolbar.mjs — the hall's shared bulk toolbar (task 1003141).
|
|
2
|
+
//
|
|
3
|
+
// One toolbar, two boards: /work and the goal board build the SAME one from
|
|
4
|
+
// modules/hall-ui/public/bulk-toolbar.js. This runs the real module (it is
|
|
5
|
+
// node-require-safe) against a stub page, so what it proves is behaviour — a
|
|
6
|
+
// release selection really enables Bulk release, a cycle really disables the
|
|
7
|
+
// sequential prompt — rather than that two files agree on a spelling.
|
|
8
|
+
import { test } from 'node:test';
|
|
9
|
+
import assert from 'node:assert/strict';
|
|
10
|
+
import fs from 'node:fs';
|
|
11
|
+
import path from 'node:path';
|
|
12
|
+
import { createRequire } from 'node:module';
|
|
13
|
+
|
|
14
|
+
const require = createRequire(import.meta.url);
|
|
15
|
+
const ROOT = path.resolve(import.meta.dirname, '..');
|
|
16
|
+
const PUB = path.join(ROOT, 'modules/hall-ui/public');
|
|
17
|
+
const read = (f) => fs.readFileSync(path.join(PUB, f), 'utf8');
|
|
18
|
+
const { topoOrder, buildBulkParallelPrompt, buildBulkSequentialPrompt, makeBulkToolbar } = require(path.join(PUB, 'bulk-toolbar.js'));
|
|
19
|
+
|
|
20
|
+
const loc = { hostname: 'builders.example.com', origin: 'https://builders.example.com' };
|
|
21
|
+
|
|
22
|
+
test('topoOrder puts a dependency first and ignores edges outside the selection', () => {
|
|
23
|
+
const edges = new Map([[2, [1]], [1, [99]], [3, []]]);
|
|
24
|
+
const order = topoOrder([2, 1, 3], edges);
|
|
25
|
+
assert.ok(order.indexOf(1) < order.indexOf(2), 'a dependency precedes its dependent');
|
|
26
|
+
assert.equal(order.length, 3);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test('topoOrder answers null for a cycle', () => {
|
|
30
|
+
assert.equal(topoOrder([1, 2], new Map([[1, [2]], [2, [1]]])), null);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test('the prompts name every task and the hall link', () => {
|
|
34
|
+
const tasks = [{ id: 5, title: 'Five' }, { id: 6, title: 'Six' }];
|
|
35
|
+
const par = buildBulkParallelPrompt(tasks, loc);
|
|
36
|
+
assert.match(par, /2 Bongos tasks IN PARALLEL/);
|
|
37
|
+
assert.match(par, /Session 2 — task 6 \(Six\) — https:\/\/builders\.example\.com#\/task\/6/);
|
|
38
|
+
const seq = buildBulkSequentialPrompt(tasks, { hostname: 'example.com', origin: 'https://example.com' });
|
|
39
|
+
assert.match(seq, /IN ORDER/);
|
|
40
|
+
assert.match(seq, /1\. Task 5 \(Five\) — https:\/\/example\.com\/builders#\/task\/5/);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
// --- the factory, against a stub page --------------------------------------
|
|
44
|
+
|
|
45
|
+
function stubPage(present) {
|
|
46
|
+
const els = {};
|
|
47
|
+
for (const id of present) els[id] = { hidden: true, disabled: false, textContent: '', title: '', listeners: {}, addEventListener(t, fn) { this.listeners[t] = fn; } };
|
|
48
|
+
globalThis.document = { getElementById: (id) => els[id] || null };
|
|
49
|
+
globalThis.window = { confirm: () => true };
|
|
50
|
+
globalThis.location = loc;
|
|
51
|
+
return els;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function build(els, over = {}) {
|
|
55
|
+
const calls = { claimMany: [], releaseMany: [], copied: [], toasts: [] };
|
|
56
|
+
const selection = { claim: new Set(), release: new Map() };
|
|
57
|
+
const tasks = new Map([[1, { id: 1, title: 'One' }], [2, { id: 2, title: 'Two' }]]);
|
|
58
|
+
const bar = makeBulkToolbar({
|
|
59
|
+
ids: { bar: 'bar', count: 'count', claim: 'claim', release: 'release', parallel: 'parallel', sequential: 'sequential', clear: 'clear' },
|
|
60
|
+
selection,
|
|
61
|
+
findTask: (id) => tasks.get(id),
|
|
62
|
+
claimAction: {
|
|
63
|
+
validateMany: async () => ({ ok: true, parallelSafe: true, byId: new Map() }),
|
|
64
|
+
claimMany: async (ids) => { calls.claimMany.push(ids); return { ok: true }; },
|
|
65
|
+
releaseMany: async (e) => { calls.releaseMany.push(e); return { ok: true }; },
|
|
66
|
+
},
|
|
67
|
+
getJSON: async () => ({ task: { dependencies: [] } }),
|
|
68
|
+
API: '/api/bongos',
|
|
69
|
+
copyText: async (t) => { calls.copied.push(t); return true; },
|
|
70
|
+
toast: (m) => calls.toasts.push(m),
|
|
71
|
+
curLabel: () => 'credits',
|
|
72
|
+
describeReason: (r) => r,
|
|
73
|
+
onClear: () => { calls.cleared = true; },
|
|
74
|
+
...over,
|
|
75
|
+
});
|
|
76
|
+
return { bar, selection, calls };
|
|
77
|
+
}
|
|
78
|
+
const settle = () => new Promise((r) => setTimeout(r, 1000));
|
|
79
|
+
|
|
80
|
+
test('nothing selected hides the bar', () => {
|
|
81
|
+
const els = stubPage(['bar', 'count', 'claim', 'release', 'parallel', 'sequential', 'clear']);
|
|
82
|
+
const { bar } = build(els);
|
|
83
|
+
els.bar.hidden = false;
|
|
84
|
+
bar.render();
|
|
85
|
+
assert.equal(els.bar.hidden, true);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test('a release selection alone enables Bulk release and leaves claim off', () => {
|
|
89
|
+
const els = stubPage(['bar', 'count', 'claim', 'release', 'parallel', 'sequential', 'clear']);
|
|
90
|
+
const { bar, selection } = build(els);
|
|
91
|
+
selection.release.set('77', '9');
|
|
92
|
+
bar.render();
|
|
93
|
+
assert.equal(els.bar.hidden, false);
|
|
94
|
+
assert.equal(els.count.textContent, '1 to release');
|
|
95
|
+
assert.equal(els.release.disabled, false);
|
|
96
|
+
assert.equal(els.claim.disabled, true);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test('two ready tasks enable both prompts once the checks come back, and the prompts copy', async () => {
|
|
100
|
+
const els = stubPage(['bar', 'count', 'claim', 'release', 'parallel', 'sequential', 'clear']);
|
|
101
|
+
const { bar, selection, calls } = build(els);
|
|
102
|
+
selection.claim.add('1'); selection.claim.add('2');
|
|
103
|
+
bar.render();
|
|
104
|
+
assert.equal(els.parallel.disabled, true, 'disabled while the dry run is pending');
|
|
105
|
+
await settle();
|
|
106
|
+
assert.equal(els.parallel.disabled, false);
|
|
107
|
+
assert.equal(els.sequential.disabled, false);
|
|
108
|
+
assert.equal(els.claim.textContent, 'Bulk claim (2)');
|
|
109
|
+
bar.wire();
|
|
110
|
+
await els.parallel.listeners.click();
|
|
111
|
+
await els.sequential.listeners.click();
|
|
112
|
+
assert.equal(calls.copied.length, 2);
|
|
113
|
+
assert.match(calls.copied[0], /IN PARALLEL/);
|
|
114
|
+
assert.match(calls.copied[1], /IN ORDER/);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test('a dependency cycle disables the sequential prompt with the reason', async () => {
|
|
118
|
+
const els = stubPage(['bar', 'count', 'claim', 'release', 'parallel', 'sequential', 'clear']);
|
|
119
|
+
const edges = { 1: [2], 2: [1] };
|
|
120
|
+
const { bar, selection } = build(els, { getJSON: async (p) => ({ task: { dependencies: edges[Number(p.split('/').pop())].map((id) => ({ id })) } }) });
|
|
121
|
+
selection.claim.add('1'); selection.claim.add('2');
|
|
122
|
+
bar.render();
|
|
123
|
+
await settle();
|
|
124
|
+
assert.equal(els.sequential.disabled, true);
|
|
125
|
+
assert.match(els.sequential.title, /cycle/);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test('a page without the optional buttons simply has no such feature', async () => {
|
|
129
|
+
const els = stubPage(['bar', 'count', 'claim', 'clear']);
|
|
130
|
+
const { bar, selection } = build(els);
|
|
131
|
+
selection.claim.add('1'); selection.claim.add('2');
|
|
132
|
+
assert.doesNotThrow(() => bar.render());
|
|
133
|
+
assert.doesNotThrow(() => bar.wire());
|
|
134
|
+
assert.equal(els.claim.textContent, 'Bulk claim (2)');
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test('Bulk claim and Bulk release go through the shared action and empty their selection', async () => {
|
|
138
|
+
const els = stubPage(['bar', 'count', 'claim', 'release', 'parallel', 'sequential', 'clear']);
|
|
139
|
+
const { bar, selection, calls } = build(els);
|
|
140
|
+
selection.claim.add('1'); selection.release.set('77', '9');
|
|
141
|
+
bar.wire();
|
|
142
|
+
await els.claim.listeners.click();
|
|
143
|
+
assert.deepEqual(calls.claimMany, [[1]]);
|
|
144
|
+
assert.equal(selection.claim.size, 0);
|
|
145
|
+
await els.release.listeners.click();
|
|
146
|
+
assert.deepEqual(calls.releaseMany, [[['77', '9']]]);
|
|
147
|
+
assert.equal(selection.release.size, 0);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test('Clear empties both selections and lets the page repaint its cards', () => {
|
|
151
|
+
const els = stubPage(['bar', 'count', 'claim', 'release', 'parallel', 'sequential', 'clear']);
|
|
152
|
+
const { bar, selection, calls } = build(els);
|
|
153
|
+
selection.claim.add('1'); selection.release.set('77', '9');
|
|
154
|
+
bar.clearSelection();
|
|
155
|
+
assert.equal(selection.claim.size + selection.release.size, 0);
|
|
156
|
+
assert.equal(calls.cleared, true);
|
|
157
|
+
assert.equal(els.bar.hidden, true);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
// --- the two boards use it, and neither keeps a private copy -----------------
|
|
161
|
+
|
|
162
|
+
test('both boards build the shared toolbar and carry all five buttons', () => {
|
|
163
|
+
const work = read('work.html');
|
|
164
|
+
const goals = read('goals.html');
|
|
165
|
+
for (const k of ['claim', 'release', 'parallel', 'sequential', 'clear']) {
|
|
166
|
+
assert.match(work, new RegExp(`id="bulk-${k}-btn"`), `work.html has bulk-${k}-btn`);
|
|
167
|
+
assert.match(goals, new RegExp(`id="goal-bulk-${k}-btn"`), `goals.html has goal-bulk-${k}-btn`);
|
|
168
|
+
}
|
|
169
|
+
assert.match(read('work.js'), /OTBBulkToolbar\.makeBulkToolbar\(/);
|
|
170
|
+
assert.match(read('goals-render.js'), /OTBBulkToolbar\.makeBulkToolbar\(/);
|
|
171
|
+
for (const [page, scripts] of [['work.html', ['bulk-toolbar.js', 'work.js']], ['goals.html', ['bulk-toolbar.js', 'goals-render.js']]]) {
|
|
172
|
+
const html = read(page);
|
|
173
|
+
const at = (n) => html.indexOf(`src="/builders/${n}`);
|
|
174
|
+
assert.ok(at(scripts[0]) > -1 && at(scripts[0]) < at(scripts[1]), `${page}: bulk-toolbar.js loads before ${scripts[1]}`);
|
|
175
|
+
}
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test('no board keeps a private copy of the toolbar logic', () => {
|
|
179
|
+
for (const f of ['work.js', 'goals-page.js', 'goals-render.js']) {
|
|
180
|
+
const src = read(f);
|
|
181
|
+
assert.doesNotMatch(src, /function topoOrder|function buildBulk(Parallel|Sequential)Prompt|function scheduleValidate|function doBulk(Claim|Release)|function doGoalBulkClaim/, `${f} re-grew toolbar logic`);
|
|
182
|
+
}
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test('the goal board offers a release checkbox on the viewer\'s own cards, and it styles', () => {
|
|
186
|
+
const render = read('goals-render.js');
|
|
187
|
+
assert.match(render, /class="wip__select" data-claim-id=/, 'a mine-active card carries the release checkbox');
|
|
188
|
+
assert.match(read('goals-page.js'), /closest\('\.task__select, \.wip__select'\)/, 'the card list listens for it');
|
|
189
|
+
assert.match(read('style.css'), /\n\.wip__select \{/, '.wip__select is in the shared sheet the goal board loads');
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test('goals.html no longer borrows work.css, and work.css no longer holds what it lent', () => {
|
|
193
|
+
assert.doesNotMatch(read('goals.html'), /work\.css/, 'the goal board is off the board-only sheet');
|
|
194
|
+
const work = read('work.css');
|
|
195
|
+
for (const sel of ['.claim-count-line', '.wip__release', '.wip__select']) {
|
|
196
|
+
assert.ok(!new RegExp(`^${sel.replace('.', '\\.')}(\\s|:|,|\\{)`, 'm').test(work), `work.css must not restate ${sel}`);
|
|
197
|
+
}
|
|
198
|
+
const style = read('style.css');
|
|
199
|
+
for (const sel of ['.claim-count-line', '.wip__release', '.wip__select']) {
|
|
200
|
+
assert.ok(new RegExp(`^${sel.replace('.', '\\.')} \\{`, 'm').test(style), `style.css carries ${sel}`);
|
|
201
|
+
}
|
|
202
|
+
});
|