@bongos/core 1.19.807 → 1.19.809

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.
@@ -356,6 +356,29 @@
356
356
 
357
357
  /* ---- the actions --------------------------------------------------------- */
358
358
  .rec-actions { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
359
+
360
+ /* task 1002858 — the shaping verbs on the task record.
361
+
362
+ The RETIRE row sits under the button row rather than in it, and is separated
363
+ by a rule: it is the one control on this page that destroys the plan instead
364
+ of advancing it, so it does not share a line with Ship it and Copy prompt. */
365
+ .rec-actions--retire { margin: 14px 0 0; padding: 12px 0 0; border-top: var(--line) solid var(--rule-soft); }
366
+ .td-btn--danger { color: var(--danger); border-color: var(--danger); }
367
+ .td-btn--danger:hover:not(:disabled) { background: var(--danger-weak); color: var(--danger); border-color: var(--danger); }
368
+
369
+ /* The × that cuts a dependency edge. Quiet until the row is hovered or it is
370
+ focused, because the list's job is to be READ — the control is for the rare
371
+ moment the chain is wrong, and a column of red crosses would say otherwise. */
372
+ .rec-dep__cut {
373
+ flex: none; margin-left: 4px; padding: 0 6px; min-width: 24px; line-height: 20px;
374
+ background: none; border: var(--line) solid transparent; border-radius: var(--radius-sm);
375
+ color: var(--ink-faint); font-size: 15px; cursor: pointer; opacity: 0;
376
+ }
377
+ .rec-deplist li:hover .rec-dep__cut, .rec-dep__cut:focus-visible { opacity: 1; }
378
+ .rec-dep__cut:hover:not(:disabled) { color: var(--danger); border-color: var(--danger); }
379
+ .rec-dep__cut:disabled { color: var(--ink-faint); cursor: default; opacity: 1; }
380
+ /* Touch has no hover, so the control must simply be there. */
381
+ @media (hover: none) { .rec-dep__cut { opacity: 1; } }
359
382
  /* Where a lifecycle control would be but is not, the record says why in words.
360
383
  Nothing here is pressable-or-live, so nothing here is warm. */
361
384
  .rec-actnote { margin: 0; flex: 1 1 240px; font-size: 14px; color: var(--ink-soft); max-width: var(--measure); }
@@ -7,10 +7,18 @@
7
7
  // the page itself (SIGNEDIN_BUILDERS_PAGE_RE + the two-segment TASK_DETAIL_PAGE_RE),
8
8
  // so an unauthenticated visitor never reaches this script.
9
9
  //
10
- // READ + NAVIGATION ONLY (task 751 scope) — no claim/release/ship action lives
11
- // here. The "Copy prompt" button is purely client-side (clipboard write, no
12
- // server call), same posture as the Work Board's copy action. Claim/release
13
- // from the browser is the dependent follow-up task.
10
+ // NOT READ-ONLY ANY MORE, and this comment is the third thing to say so: task
11
+ // 751 opened the page read+navigation only, task 759 added Ship it / Confirm,
12
+ // task 1003063 added the goal move, and task 1002858 adds the task-SHAPING
13
+ // verbs — priority, dependency add/remove, and abandon. "Copy prompt" is still
14
+ // the only purely client-side control (a clipboard write, no server call).
15
+ //
16
+ // THE RULE EVERY WRITE ON THIS PAGE FOLLOWS (ADR 0016): the server owns every
17
+ // rank and ownership gate; this file only decides which control to SURFACE, and
18
+ // maps a refusal to a sentence. isSupervisor here is the client's read of the
19
+ // Metic floor those permissions share (task.edit, task.abandon,
20
+ // task.dependency.manage) — it is a display hint, never the gate. Claim/release
21
+ // from the browser is still the dependent follow-up task.
14
22
 
15
23
  (() => {
16
24
  'use strict';
@@ -21,6 +29,9 @@
21
29
  // builds through unchanged; result-mode ({ status, ok, data }) preserves control flow 1:1.
22
30
  const api = window.BongosClient.createClient({ baseUrl: '', credentials: 'same-origin', throwOnError: false });
23
31
  const AUTH_NEEDED = 'auth-needed';
32
+ // task 1002858 — whether the retire disclosure is expanded. Module-level
33
+ // because renderTask() rebuilds #task-body wholesale on every call.
34
+ let abandonOpen = false;
24
35
  const { escapeHtml, $, fmtDate, errCode, errMessage } = window.OTB; // task 1002462: envelope readers
25
36
  const toast = (msg) => window.OTB.toast(msg, { duration: 2600 });
26
37
  const curLabel = () => (window.__BRANDING__ && window.__BRANDING__.currency && window.__BRANDING__.currency.label) || 'credits';
@@ -84,6 +95,24 @@
84
95
  return data;
85
96
  }
86
97
 
98
+ // task 1002858: mutating call for removing a dependency edge. Same posture as
99
+ // postJSON/patchJSON — resolves to the parsed 2xx body, throws an Error carrying
100
+ // { status, data } so the caller can print the server's own refusal. DELETE
101
+ // carries no body by design; the edge to cut is named in the path.
102
+ async function delJSON(path) {
103
+ const res = await api.request('DELETE', path);
104
+ if (res.status === 401) { toAuth(); throw new Error('redirecting to auth'); }
105
+ let data = null;
106
+ try { data = res.data; } catch (_) { /* empty / non-JSON body */ }
107
+ if (!res.ok) {
108
+ const err = new Error(`${path} → ${res.status}`);
109
+ err.status = res.status;
110
+ err.data = data || {};
111
+ throw err;
112
+ }
113
+ return data;
114
+ }
115
+
87
116
  // Map the server's typed lifecycle-error to a builder-readable line. Codes are
88
117
  // exactly those POST /tasks/:id/ship + /confirm can return. The server is the
89
118
  // authority (ADR 0016); the UI only surfaces the refusal.
@@ -235,11 +264,21 @@
235
264
  return `<span class="rec-pill${tone ? ` rec-pill--${tone}` : ''}${extraClass ? ` ${extraClass}` : ''}">${escapeHtml(s)}</span>`;
236
265
  }
237
266
 
238
- function depList(kindWord, items) {
267
+ // `removable` is passed ONLY for "Blocked by" (task 1002858), and the asymmetry
268
+ // is deliberate. DELETE /tasks/:id/dependencies/:depId removes the edge where
269
+ // THIS task depends on another, which is this record's own business. The
270
+ // "Blocks" list is the same edges seen from the far end: dropping one there
271
+ // would be editing a DIFFERENT task's dependency set from a page that is not
272
+ // showing it, with no sight of why that other task was made to wait. Open that
273
+ // task and remove it there.
274
+ function depList(kindWord, items, { removable = false } = {}) {
239
275
  const rows = items.map((d) => {
240
276
  const status = d.status ? statusPill(d.status, 'rec-pill--sm') : '';
277
+ const cut = removable
278
+ ? `<button class="rec-dep__cut" type="button" data-cut-dep="${escapeHtml(String(d.id))}" title="Stop waiting on task ${escapeHtml(String(d.id))}" aria-label="Stop waiting on task ${escapeHtml(String(d.id))}">×</button>`
279
+ : '';
241
280
  return `<li><a class="rec-dep" href="${hallBase()}/task/${escapeHtml(String(d.id))}">` +
242
- `<b class="rec-dep__id">${escapeHtml(String(d.id))}</b>${escapeHtml(d.title || 'task')}</a>${status}</li>`;
281
+ `<b class="rec-dep__id">${escapeHtml(String(d.id))}</b>${escapeHtml(d.title || 'task')}</a>${status}${cut}</li>`;
243
282
  }).join('');
244
283
  return `<section><p class="rec-sec__h">${escapeHtml(kindWord)}</p><ul class="rec-deplist">${rows}</ul></section>`;
245
284
  }
@@ -459,6 +498,21 @@
459
498
  // gates read the same rank once, rather than risking drift between copies.
460
499
  const rank = (viewer && viewer.rank) ? String(viewer.rank).toLowerCase() : '';
461
500
  const isSupervisor = rank === 'metic' || rank === 'archon';
501
+ // task 1002858 — the SHAPING verbs (priority, dependencies, abandon) share one
502
+ // floor: task.edit, task.dependency.manage and task.abandon are all Metic+
503
+ // (ADR 0090; abandon re-ranked down from Archon by ADR 0152). A separate name
504
+ // from isSupervisor because they are separate ideas that merely agree today —
505
+ // if one of those permissions moves, this line moves and the lifecycle
506
+ // controls do not. It is a DISPLAY hint: the server re-checks every one.
507
+ //
508
+ // Goal LEAD status deliberately confers nothing here. Whether leading a goal
509
+ // should carry these permissions is a governance change that needs its own
510
+ // ADR, so this surfaces controls only for builders who already hold the atoms.
511
+ const canShape = isSupervisor;
512
+ // render() rebuilds #task-body wholesale, so a disclosure cannot keep its
513
+ // state in the DOM. This re-renders the SAME data without a refetch — the
514
+ // toggle is a view change, not a reason to ask the server anything.
515
+ const rerender = () => renderTask(t, lastClaim, viewer, goal, extras);
462
516
 
463
517
  const parts = [];
464
518
  parts.push(`<p class="rec-kind">Task ${statusPill(t.status)}</p>`);
@@ -520,6 +574,41 @@
520
574
  `);
521
575
  }
522
576
 
577
+ // task 1002858 — the two remaining SHAPING verbs, next to the goal move
578
+ // because they are the same kind of act: correcting how the work is routed
579
+ // and ordered, not moving it through its lifecycle.
580
+ //
581
+ // PRIORITY IS NOT COSMETIC, which is why it earns a control rather than a
582
+ // note: the DB weights version progress as `6 - COALESCE(priority, 5)` and
583
+ // the session optimizer orders claimable work by `priority ASC NULLS LAST`,
584
+ // so a wrong value at create time steers what every builder claims until
585
+ // someone fixes it. Until task 1003750 it was write-once at every rank and a
586
+ // whole-goal re-rank had to run as raw SQL. 1..5 mirrors tasks_priority_check;
587
+ // the empty option clears it back to unset, which is a real state (NULLS LAST)
588
+ // and not the same as P5.
589
+ if (canShape) {
590
+ const pOpts = ['', '1', '2', '3', '4', '5'].map((v) => {
591
+ const cur = t.priority == null ? '' : String(t.priority);
592
+ const label = v === '' ? '— unset —' : `P${v}`;
593
+ return `<option value="${v}"${v === cur ? ' selected' : ''}>${label}</option>`;
594
+ }).join('');
595
+ parts.push(`
596
+ <div class="td-form" id="shape-form">
597
+ <label class="td-field"><span>Priority</span>
598
+ <select id="priority-select">${pOpts}</select>
599
+ </label>
600
+ <label class="td-field"><span>Wait for task</span>
601
+ <input id="dep-add-input" type="number" min="1" step="1" inputmode="numeric" placeholder="task id">
602
+ </label>
603
+ <div class="td-actions">
604
+ <button type="button" class="td-btn" id="priority-btn">Set priority</button>
605
+ <button type="button" class="td-btn" id="dep-add-btn">Add dependency</button>
606
+ </div>
607
+ <p class="td-form-hint">A dependency keeps this task out of the claimable queue until that one ships. Remove one with the × beside it under “Blocked by”.</p>
608
+ </div>
609
+ `);
610
+ }
611
+
523
612
  const creditVal = window.OTBBranding?.taskCreditValue?.(t) ?? t.credits_reward;
524
613
  parts.push(figureBand([
525
614
  [window.OTBBranding?.taskCreditLabel?.(t) ?? curLabel(), creditVal],
@@ -577,7 +666,7 @@
577
666
  const deps = Array.isArray(t.dependencies) ? t.dependencies : [];
578
667
  const dependents = Array.isArray(t.dependents) ? t.dependents : [];
579
668
  if (deps.length || dependents.length) {
580
- parts.push(`<div class="rec-sec rec-depcols">${deps.length ? depList('Blocked by', deps) : ''}${dependents.length ? depList('Blocks', dependents) : ''}</div>`);
669
+ parts.push(`<div class="rec-sec rec-depcols">${deps.length ? depList('Blocked by', deps, { removable: canShape }) : ''}${dependents.length ? depList('Blocks', dependents) : ''}</div>`);
581
670
  }
582
671
 
583
672
  if (t.shipped_at) {
@@ -614,6 +703,40 @@
614
703
  lifecycle = `<p class="rec-actnote">This task has shipped and its credits have landed. Neither Ship it nor Confirm is available.</p>`;
615
704
  }
616
705
 
706
+ // task 1002858 — RETIRE. Kept out of the button row and behind a disclosure
707
+ // because it is the one control here that destroys the plan rather than
708
+ // advancing it, and because the server demands a reason it can put in the
709
+ // audit log.
710
+ //
711
+ // THE TWO REFUSALS ARE PRE-ANSWERED IN WORDS RATHER THAN OFFERED AS A BUTTON
712
+ // THAT CANNOT WORK. A shipped task is immutable history, and a task with an
713
+ // active claim must be released first — both are 409s the server will raise
714
+ // anyway, and both are knowable from this record. Offering the control and
715
+ // letting it fail would teach the reader that the page guesses.
716
+ const claimIsLive = !!(lastClaim && !lastClaim.released_at);
717
+ let retire = '';
718
+ if (canShape && t.status === 'shipped') {
719
+ retire = '<p class="rec-actnote">A shipped task cannot be retired — it is immutable history now.</p>';
720
+ } else if (canShape && claimIsLive) {
721
+ retire = '<p class="rec-actnote">Someone is holding a claim on this task. It has to be released before the task can be retired.</p>';
722
+ } else if (canShape && t.status === 'abandoned') {
723
+ retire = '<p class="rec-actnote">This task is already retired. Promote restores it to the backlog.</p>';
724
+ } else if (canShape) {
725
+ retire = abandonOpen
726
+ ? `
727
+ <div class="td-form" id="abandon-form">
728
+ <label class="td-field td-field--wide"><span>Why retire this task?</span>
729
+ <input id="abandon-reason" type="text" maxlength="2000" placeholder="e.g. superseded by task 1002891">
730
+ </label>
731
+ <div class="td-actions">
732
+ <button type="button" class="td-btn td-btn--danger" id="abandon-btn">Retire this task</button>
733
+ <button type="button" class="td-btn" id="abandon-cancel-btn">Cancel</button>
734
+ </div>
735
+ <p class="td-form-hint">The reason is recorded in the audit log. This is recoverable: a retired task can be restored to the backlog with Promote.</p>
736
+ </div>`
737
+ : '<button class="td-btn" type="button" id="abandon-open-btn">Retire this task…</button>';
738
+ }
739
+
617
740
  // Copy prompt (the "ship link") — the same claim/build/ship prompt the Work
618
741
  // Board hands an agent. Purely client-side (clipboard write, no server call).
619
742
  parts.push(`
@@ -624,6 +747,7 @@
624
747
  <button class="td-btn" type="button" id="copy-prompt-btn">Copy prompt</button>
625
748
  ${exitLinkHtml()}
626
749
  </div>
750
+ ${retire ? `<div class="rec-actions rec-actions--retire">${retire}</div>` : ''}
627
751
  </section>
628
752
  `);
629
753
 
@@ -660,6 +784,99 @@
660
784
  // task 1003063 (R10): PATCH goal_id, then a full reload so the goal line, the
661
785
  // picker's preselected option and everything else on the record re-render
662
786
  // against the server's own state — no local patchwork of the DOM.
787
+ // task 1002858 — the shaping handlers. Each one: disable while in flight, send,
788
+ // then `load()` so the whole record re-renders against the SERVER's state
789
+ // rather than a local patch of the DOM (the idiom the goal move set below).
790
+ // Every refusal prints the server's own sentence where it has one — those are
791
+ // more specific than anything this page could invent, and one of them
792
+ // (dependency.cross_tier.manage, Archon-only under ADR 0157) names a gate this
793
+ // file deliberately does not model rather than re-deriving it client-side.
794
+ async function runWrite(btn, fn, okMsg) {
795
+ if (!btn || btn.disabled) return;
796
+ const orig = btn.textContent;
797
+ btn.disabled = true;
798
+ btn.textContent = '…';
799
+ try {
800
+ await fn();
801
+ toast(okMsg);
802
+ await load();
803
+ } catch (err) {
804
+ if (err && err.message === 'redirecting to auth') return;
805
+ toast(errMessage(err && err.data) || `That did not go through (${(err && err.status) || '?'}). Try again.`);
806
+ btn.disabled = false;
807
+ btn.textContent = orig;
808
+ }
809
+ }
810
+
811
+ const priorityBtn = $('#priority-btn');
812
+ if (priorityBtn) {
813
+ priorityBtn.addEventListener('click', () => {
814
+ const raw = $('#priority-select')?.value ?? '';
815
+ // '' is the unset option, and it is sent as null ON PURPOSE — "no priority"
816
+ // is a real state the optimizer sorts last (NULLS LAST), not a synonym for
817
+ // P5. Sending nothing at all would 400 as no_supported_fields.
818
+ const priority = raw === '' ? null : Number(raw);
819
+ if (priority !== null && !Number.isInteger(priority)) { toast('Pick a priority first.'); return; }
820
+ const was = t.priority == null ? 'unset' : `P${t.priority}`;
821
+ const now = priority == null ? 'unset' : `P${priority}`;
822
+ if (was === now) { toast(`Priority is already ${now}.`); return; }
823
+ runWrite(priorityBtn,
824
+ () => patchJSON(`${API}/tasks/${encodeURIComponent(t.id)}`, { priority }),
825
+ `Priority ${was} → ${now}.`);
826
+ });
827
+ }
828
+
829
+ const depAddBtn = $('#dep-add-btn');
830
+ if (depAddBtn) {
831
+ depAddBtn.addEventListener('click', () => {
832
+ const raw = $('#dep-add-input')?.value ?? '';
833
+ const depId = Number(raw);
834
+ if (!Number.isInteger(depId) || depId <= 0) { toast('Enter the id of the task this one should wait for.'); return; }
835
+ // Caught here only to say something better than the server's 'self_dep';
836
+ // the server still refuses it if this check is ever wrong.
837
+ if (String(depId) === String(t.id)) { toast('A task cannot wait for itself.'); return; }
838
+ runWrite(depAddBtn,
839
+ () => postJSON(`${API}/tasks/${encodeURIComponent(t.id)}/dependencies`, { depends_on_task_id: depId }),
840
+ `Task ${t.id} now waits for task ${depId}.`);
841
+ });
842
+ }
843
+
844
+ // Wired per row, because the × lives inside a list this function rebuilds on
845
+ // every render and the nodes are replaced with it.
846
+ for (const cut of document.querySelectorAll('[data-cut-dep]')) {
847
+ cut.addEventListener('click', () => {
848
+ const depId = cut.getAttribute('data-cut-dep');
849
+ runWrite(cut,
850
+ () => delJSON(`${API}/tasks/${encodeURIComponent(t.id)}/dependencies/${encodeURIComponent(depId)}`),
851
+ `Task ${t.id} no longer waits for task ${depId}.`);
852
+ });
853
+ }
854
+
855
+ const abandonOpenBtn = $('#abandon-open-btn');
856
+ if (abandonOpenBtn) {
857
+ abandonOpenBtn.addEventListener('click', () => { abandonOpen = true; rerender(); });
858
+ }
859
+ const abandonCancelBtn = $('#abandon-cancel-btn');
860
+ if (abandonCancelBtn) {
861
+ abandonCancelBtn.addEventListener('click', () => { abandonOpen = false; rerender(); });
862
+ }
863
+ const abandonBtn = $('#abandon-btn');
864
+ if (abandonBtn) {
865
+ abandonBtn.addEventListener('click', () => {
866
+ const reason = ($('#abandon-reason')?.value || '').trim();
867
+ // A REASON IS REQUIRED HERE THOUGH THE SERVER ACCEPTS AN EMPTY ONE. The
868
+ // value of the audit_log row is the sentence in it; a retire with a blank
869
+ // reason is the record noting that someone did this and declining to say
870
+ // why. Asking costs one line.
871
+ if (!reason) { toast('Say why it is being retired — the reason goes in the record.'); return; }
872
+ if (!window.confirm(`Retire task ${t.id}? It leaves the claimable queue. Promote restores it to the backlog.`)) return;
873
+ abandonOpen = false;
874
+ runWrite(abandonBtn,
875
+ () => postJSON(`${API}/tasks/${encodeURIComponent(t.id)}/abandon`, { reason }),
876
+ `Task ${t.id} retired. Promote restores it to the backlog.`);
877
+ });
878
+ }
879
+
663
880
  const moveGoalBtn = $('#move-goal-btn');
664
881
  if (moveGoalBtn) {
665
882
  moveGoalBtn.addEventListener('click', async () => {
@@ -0,0 +1,103 @@
1
+ 'use strict';
2
+
3
+ // modules/lifecycle/abandon-leftovers.js — what a retired task leaves behind
4
+ // (task 1003779).
5
+ //
6
+ // THE DEFECT THIS EXISTS FOR. Abandoning a task retires the ROW and nothing else.
7
+ // Task 1002464 was abandoned carrying two commits on `task-1002464` that are not
8
+ // ancestors of origin/main, and an open PR. Nothing said so — not at the moment
9
+ // of abandonment, not afterwards — so the branch read as live work, and a
10
+ // follow-up filed off its grading pass (task 1003626) sat at `ready` asking for
11
+ // three nits in a function that exists nowhere on main. `sequence.js --check`
12
+ // called it "clear to run". The cost landed on whoever claimed it.
13
+ //
14
+ // SIBLING OF dead-deps.js, deliberately. That file makes an abandoned
15
+ // DEPENDENCY observable; this one makes abandoned CODE observable. Same posture:
16
+ // pure and git-free here so the CLI is the only place that shells out, and the
17
+ // rule can be tested without a repository.
18
+ //
19
+ // WHAT THIS IS NOT. It does not delete a branch, close a PR, or touch the
20
+ // remote. Retiring a row is reversible (POST /tasks/:id/promote); deleting the
21
+ // only copy of unmerged work is not, and which of the two a leftover branch
22
+ // deserves is a human call. This reports, loudly, with the commands to hand.
23
+ //
24
+ // WHY NOT source_ref, which the report suggested. The incident that motivated
25
+ // this task is the counter-example: 1003626's `source_ref` is `idea:1000935`,
26
+ // not the branch or task it was stranded behind — the relationship lived only in
27
+ // the prose of its description. A source_ref sweep would have found nothing.
28
+ // The branch is the artifact that actually exists, so the branch is what is
29
+ // checked.
30
+
31
+ // A branch belongs to task N if it is `task-N` or `task-N-<slug>` (the two
32
+ // shapes worktree.js produces). Anchored at both ends so task-1002450 never
33
+ // matches task-10024501 — a prefix match here would report another task's work
34
+ // as this one's leftovers, which is worse than reporting nothing.
35
+ function branchesForTask(taskId, branches) {
36
+ const id = String(taskId == null ? '' : taskId).trim();
37
+ if (!/^\d+$/.test(id)) return [];
38
+ const re = new RegExp(`^task-${id}(?:-[A-Za-z0-9._-]+)?$`);
39
+ // Deduped after stripping the remote prefix: a caller may pass the same branch
40
+ // as both 'task-N' and 'origin/task-N' (git branch -r vs ls-remote), and the
41
+ // notice would then tell a builder to delete one branch twice.
42
+ const seen = new Set();
43
+ for (const raw of branches || []) {
44
+ const b = String(raw || '').trim().replace(/^origin\//, '');
45
+ if (re.test(b)) seen.add(b);
46
+ }
47
+ return [...seen];
48
+ }
49
+
50
+ // Pure: the leftover report for one task. `aheadCount(branch)` answers "how many
51
+ // commits does this branch carry that main does not" — a number, or null when it
52
+ // could not be determined.
53
+ //
54
+ // null is NOT folded into 0. "I could not look" and "there is nothing there"
55
+ // must not render as the same sentence (the strand-watch.js rule): an
56
+ // undetermined branch is reported as undetermined, so a network failure cannot
57
+ // quietly certify a branch as clean.
58
+ function leftoversForTask(taskId, { branches, aheadCount }) {
59
+ const out = [];
60
+ for (const branch of branchesForTask(taskId, branches)) {
61
+ let raw;
62
+ try { raw = aheadCount(branch); } catch (_) { raw = null; }
63
+ const n = Number(raw);
64
+ // Anything that is not a finite number is UNDETERMINED, not zero. The first
65
+ // cut of this tested only for null/undefined, so a NaN — what
66
+ // `Number(git rev-list output)` yields when the command prints nothing —
67
+ // fell through to the "> 0" test, failed it, and was dropped as clean. That
68
+ // is the exact failure this module's header forbids: a branch nobody could
69
+ // read certified as a branch with nothing on it.
70
+ if (raw === null || raw === undefined || !Number.isFinite(n)) {
71
+ out.push({ branch, ahead: null, undetermined: true });
72
+ continue;
73
+ }
74
+ if (n > 0) out.push({ branch, ahead: n, undetermined: false });
75
+ }
76
+ return out;
77
+ }
78
+
79
+ // The message a builder sees. Returns null when there is nothing to say, so the
80
+ // caller can stay silent on the clean path rather than printing a reassurance
81
+ // nobody reads (and which would train people to skip the line that matters).
82
+ function leftoverNotice(taskId, leftovers) {
83
+ if (!leftovers || !leftovers.length) return null;
84
+ const lines = [`\n⚠ Task #${taskId} is retired, but its work is still on the remote:`];
85
+ for (const l of leftovers) {
86
+ lines.push(l.undetermined
87
+ ? ` - ${l.branch} — could not tell whether it carries unmerged commits`
88
+ : ` - ${l.branch} — ${l.ahead} commit(s) not on main`);
89
+ }
90
+ lines.push(
91
+ '',
92
+ 'An abandoned task with a live branch reads as live work, and anything filed',
93
+ 'off its review sits against code that does not exist on main. Decide which:',
94
+ '',
95
+ ` git push origin --delete ${leftovers[0].branch} # the work is cut — drop it`,
96
+ ` node scripts/gds/api.js POST /api/gds/tasks/${taskId}/promote # it is not cut — revive the task`,
97
+ '',
98
+ 'Close or draft the branch\'s PR either way — the remote is not this tool\'s to change.',
99
+ );
100
+ return lines.join('\n');
101
+ }
102
+
103
+ module.exports = { branchesForTask, leftoversForTask, leftoverNotice };
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.807",
3
+ "version": "1.19.809",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.807",
9
+ "version": "1.19.809",
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.807",
3
+ "version": "1.19.809",
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",
@@ -21,6 +21,38 @@ const { cliClient, requireSession, arg } = require('./cli-lib');
21
21
  // task 1003153 — the live-status set, shared with the dead-edge detector so
22
22
  // "who did this cut strand" means the same thing in both places.
23
23
  const { LIVE_STATUSES } = require('../../modules/lifecycle/dead-deps.js');
24
+ // task 1003779 — the other thing a cut leaves behind: the code itself.
25
+ const { leftoversForTask, leftoverNotice } = require('../../modules/lifecycle/abandon-leftovers.js');
26
+ const { execFileSync } = require('node:child_process');
27
+
28
+ // Best-effort git, always. Every call here is advisory decoration on an abandon
29
+ // that has ALREADY landed, so a missing git, a detached network or a shallow
30
+ // clone must return "I don't know" — never throw, and never abort the command.
31
+ function git(args, ms = 20000) {
32
+ return execFileSync('git', args, { encoding: 'utf8', timeout: ms, stdio: ['ignore', 'pipe', 'ignore'] });
33
+ }
34
+
35
+ // The remote branches this task owns. ONE network round trip, scoped to the two
36
+ // name shapes worktree.js produces — listing all 800-odd heads to filter locally
37
+ // would be the expensive way to learn the same thing.
38
+ function remoteBranchesFor(taskId) {
39
+ try {
40
+ const out = git(['ls-remote', '--heads', 'origin', `refs/heads/task-${taskId}`, `refs/heads/task-${taskId}-*`]);
41
+ return out.split('\n').map((l) => l.split('\t')[1] || '').filter(Boolean)
42
+ .map((r) => r.replace(/^refs\/heads\//, ''));
43
+ } catch (_) { return null; } // null = could not look
44
+ }
45
+
46
+ // How many commits the branch carries that main does not. Returns null when it
47
+ // cannot be determined, which leftoversForTask reports as undetermined rather
48
+ // than folding into "clean" — see that module's header.
49
+ function commitsAheadOfMain(branch) {
50
+ try {
51
+ git(['fetch', 'origin', '--quiet', branch, 'main']);
52
+ const n = Number(String(git(['rev-list', '--count', 'FETCH_HEAD', '--not', 'origin/main'])).trim());
53
+ return Number.isFinite(n) ? n : null;
54
+ } catch (_) { return null; }
55
+ }
24
56
 
25
57
 
26
58
  (async () => {
@@ -79,6 +111,22 @@ const { LIVE_STATUSES } = require('../../modules/lifecycle/dead-deps.js');
79
111
  console.log(' Sweep for any you miss: node scripts/gds/audit-deps.js --stranded');
80
112
  }
81
113
  } catch (_) { /* the abandon already landed — the report is advisory */ }
114
+
115
+ // task 1003779 — REPORT the code this cut orphaned. Abandoning retires the ROW;
116
+ // the branch and its PR stay exactly where they were, reading as live work.
117
+ // Task 1002464 was cut carrying two commits never merged to main, and the
118
+ // follow-up filed off its review (task 1003626) then sat at 'ready' asking for
119
+ // changes to a function that exists nowhere on main — 'sequence.js --check'
120
+ // called it clear to run. Nothing had said the branch was dead, so nothing could.
121
+ // Same posture as the stranded-dependents report above: best-effort, and it
122
+ // never fails an abandon that already landed.
123
+ try {
124
+ const branches = remoteBranchesFor(taskId);
125
+ if (branches && branches.length) {
126
+ const notice = leftoverNotice(taskId, leftoversForTask(taskId, { branches, aheadCount: commitsAheadOfMain }));
127
+ if (notice) console.log(notice);
128
+ }
129
+ } catch (_) { /* advisory only */ }
82
130
  })().catch((err) => {
83
131
  console.error('fatal:', err);
84
132
  process.exit(1);
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.807'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
74
+ const CORE_VERSION = '1.19.809'; // 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');
@@ -0,0 +1,87 @@
1
+ // tests/abandon_leftovers.mjs — what a retired task leaves on the remote
2
+ // (task 1003779). Pure + git-free: the ancestry oracle is injected, so these run
3
+ // with no repository, no network and no fixtures.
4
+ import assert from 'node:assert/strict';
5
+ import { test } from 'node:test';
6
+ import { createRequire } from 'node:module';
7
+ import path from 'node:path';
8
+ import { fileURLToPath } from 'node:url';
9
+
10
+ const require = createRequire(import.meta.url);
11
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
12
+ const { branchesForTask, leftoversForTask, leftoverNotice } =
13
+ require(path.join(ROOT, 'modules', 'lifecycle', 'abandon-leftovers.js'));
14
+
15
+ test('both branch shapes worktree.js produces belong to the task', () => {
16
+ const branches = ['task-1002464', 'task-1002503-backfill-recovery', 'main'];
17
+ assert.deepEqual(branchesForTask(1002464, branches), ['task-1002464']);
18
+ assert.deepEqual(branchesForTask(1002503, branches), ['task-1002503-backfill-recovery']);
19
+ });
20
+
21
+ test('THE MISREPORT THIS MUST NOT MAKE: a longer id is not a prefix match', () => {
22
+ // task-10024501 is ANOTHER task's branch. Matching it would tell a builder to
23
+ // delete someone else's unmerged work as their own leftovers — worse than
24
+ // reporting nothing at all, which is why the pattern is anchored at both ends.
25
+ assert.deepEqual(branchesForTask(1002450, ['task-10024501', 'task-1002450x', 'xtask-1002450']), []);
26
+ assert.deepEqual(branchesForTask(1002450, ['task-1002450', 'task-10024501']), ['task-1002450']);
27
+ });
28
+
29
+ test('the same branch given twice is reported once', () => {
30
+ // Callers may pass `git branch -r` output (origin/task-N) and `ls-remote`
31
+ // output (task-N) together; the notice would otherwise say "delete it" twice.
32
+ assert.deepEqual(branchesForTask(1002464, ['task-1002464', 'origin/task-1002464']), ['task-1002464']);
33
+ });
34
+
35
+ test('a non-numeric or missing id matches nothing rather than building a loose regex', () => {
36
+ for (const id of ['', null, undefined, 'x', '10*', '.*']) {
37
+ assert.deepEqual(branchesForTask(id, ['task-1002464', 'task-x']), [], `id ${JSON.stringify(id)}`);
38
+ }
39
+ });
40
+
41
+ test('a branch already merged into main is not a leftover — the silent path', () => {
42
+ // The load-bearing negative. Every shipped task keeps its branch, so if a
43
+ // merged branch counted, the notice would fire on nearly every abandon and
44
+ // become weather (the lesson task 1003770 recorded about the drift advisory).
45
+ assert.deepEqual(leftoversForTask(1002464, { branches: ['task-1002464'], aheadCount: () => 0 }), []);
46
+ assert.equal(leftoverNotice(1002464, []), null, 'nothing to say means say nothing');
47
+ });
48
+
49
+ test('unmerged commits are reported with their count', () => {
50
+ assert.deepEqual(
51
+ leftoversForTask(1002464, { branches: ['task-1002464'], aheadCount: () => 2 }),
52
+ [{ branch: 'task-1002464', ahead: 2, undetermined: false }],
53
+ );
54
+ });
55
+
56
+ test('"could not look" is never folded into "nothing there"', () => {
57
+ // A network failure must not silently certify a branch as clean — the
58
+ // strand-watch.js rule. null and a throw both surface as undetermined.
59
+ for (const oracle of [() => null, () => undefined, () => { throw new Error('offline'); }, () => NaN]) {
60
+ const r = leftoversForTask(1002464, { branches: ['task-1002464'], aheadCount: oracle });
61
+ assert.equal(r.length, 1);
62
+ assert.equal(r[0].undetermined, true);
63
+ assert.equal(r[0].ahead, null);
64
+ }
65
+ assert.match(leftoverNotice(1002464, leftoversForTask(1002464,
66
+ { branches: ['task-1002464'], aheadCount: () => null })), /could not tell/);
67
+ });
68
+
69
+ test('the notice names the task, the branch, and both ways out', () => {
70
+ const notice = leftoverNotice(1002464, leftoversForTask(1002464,
71
+ { branches: ['task-1002464'], aheadCount: () => 2 }));
72
+ assert.match(notice, /#1002464/);
73
+ assert.match(notice, /task-1002464 — 2 commit\(s\) not on main/);
74
+ // Both dispositions, because which one applies is a human call: the work is
75
+ // cut (drop the branch) or it is not (revive the task).
76
+ assert.match(notice, /git push origin --delete task-1002464/);
77
+ assert.match(notice, /tasks\/1002464\/promote/);
78
+ });
79
+
80
+ test('a mixed set reports only the branches that actually carry work', () => {
81
+ const branches = ['task-1002464', 'task-1002464-followup'];
82
+ const r = leftoversForTask(1002464, {
83
+ branches,
84
+ aheadCount: (b) => (b === 'task-1002464' ? 3 : 0),
85
+ });
86
+ assert.deepEqual(r, [{ branch: 'task-1002464', ahead: 3, undetermined: false }]);
87
+ });