@bongos/core 1.19.807 → 1.19.808

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 () => {
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.808",
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.808",
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.808",
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/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.808'; // 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');
@@ -274,8 +274,15 @@ const MD_SRC = read('md-reader.js');
274
274
  // (default) echoes the task back at 200 with goal_id applied from the request
275
275
  // body (so a reload's GET reflects the move); { status, data } refuses it with
276
276
  // that shape instead, for the refusal-surfacing tests.
277
- function renderTask(task, { lastClaim = null, viewer = null, goal = null, goalsList = null, patch = null, status = 200, search = '', referrer = '', withReader = true, grade, sessionLogs, claims } = {}) {
277
+ // task 1002858 — `write` refuses the shaping routes the way `patch` refuses the
278
+ // goal move: { status, data }, applied to POST /dependencies, DELETE
279
+ // /dependencies/:id and POST /abandon. `writes` records method + path + body for
280
+ // all of them, because what these tests are actually about is the REQUEST the
281
+ // page issues — `requests` carries paths only and is left alone so the older
282
+ // assertions on it keep meaning what they meant.
283
+ function renderTask(task, { lastClaim = null, viewer = null, goal = null, goalsList = null, patch = null, write = null, status = 200, search = '', referrer = '', withReader = true, grade, sessionLogs, claims } = {}) {
278
284
  const requests = [];
285
+ const writes = [];
279
286
  const toasts = [];
280
287
  const els = new Map();
281
288
  const makeEl = () => ({
@@ -302,6 +309,15 @@ function renderTask(task, { lastClaim = null, viewer = null, goal = null, goalsL
302
309
  const client = {
303
310
  request: async (method, p, opts) => {
304
311
  requests.push(p);
312
+ if (method !== 'GET') writes.push({ method, path: p, body: (opts && opts.body) || null });
313
+ // task 1002858 — the shaping routes. Checked before the generic /tasks/\d+
314
+ // read below, which ignores method and would otherwise swallow them.
315
+ if (/\/tasks\/\d+\/dependencies/.test(p) || /\/tasks\/\d+\/abandon$/.test(p)) {
316
+ if (write && write.status && write.status !== 200) {
317
+ return { status: write.status, ok: false, data: write.data || null };
318
+ }
319
+ return { status: 200, ok: true, data: { ok: true } };
320
+ }
305
321
  // PATCH must be checked before the generic /tasks/\d+ read below — both
306
322
  // match the same path shape, and the read branch ignores method.
307
323
  if (method === 'PATCH' && /\/tasks\/\d+$/.test(p)) {
@@ -356,6 +372,7 @@ function renderTask(task, { lastClaim = null, viewer = null, goal = null, goalsL
356
372
  sub: () => documentObj.getElementById('task-sub').textContent,
357
373
  el: (id) => documentObj.getElementById(id),
358
374
  requests,
375
+ writes,
359
376
  toasts,
360
377
  };
361
378
  }
@@ -821,6 +838,171 @@ test('a successful move-to-goal PATCH re-renders the goal line against the new g
821
838
  'the goal line reflects the new goal after the PATCH triggers a reload');
822
839
  });
823
840
 
841
+ // ---- 11b. the task-SHAPING verbs (task 1002858) -----------------------------
842
+ //
843
+ // priority, dependency add/remove and abandon all had routes and no hall control,
844
+ // so a goal lead shaping their goal's work dropped to the CLI to retire a
845
+ // duplicate, re-rank a task or fix a dependency chain. Same posture as every
846
+ // other write on this page (ADR 0016): the server owns the gate, this only
847
+ // decides what to draw and prints the refusal it gets back.
848
+ //
849
+ // WHAT THESE TESTS ARE FOR is the REQUEST each control issues — the method, the
850
+ // path and the body — because a control that renders correctly and sends the
851
+ // wrong thing looks identical from the markup. That is the defect class the
852
+ // invite button in task 1002886 carried for months.
853
+
854
+ test('the shaping controls render for Metic/Archon and not for Xenos/Thetes', async () => {
855
+ // BASE_TASK is shipped, and a shipped task deliberately loses the retire
856
+ // control (see the guard test below) — so this one asks about a LIVE task.
857
+ const OPEN_TASK = { ...BASE_TASK, status: 'ready' };
858
+ for (const rank of ['metic', 'archon']) {
859
+ const r = renderTask(OPEN_TASK, { viewer: { rank } });
860
+ await r.boot(); await settle();
861
+ assert.match(r.html(), /id="priority-select"/, `${rank} sees the priority control`);
862
+ assert.match(r.html(), /id="dep-add-input"/, `${rank} sees the add-dependency control`);
863
+ assert.match(r.html(), /id="abandon-open-btn"/, `${rank} sees the retire control`);
864
+ }
865
+ for (const rank of ['xenos', 'thetes']) {
866
+ const r = renderTask(OPEN_TASK, { viewer: { rank } });
867
+ await r.boot(); await settle();
868
+ for (const id of ['priority-select', 'dep-add-input', 'abandon-open-btn']) {
869
+ assert.doesNotMatch(r.html(), new RegExp(`id="${id}"`), `${rank} must not see ${id}`);
870
+ }
871
+ }
872
+ });
873
+
874
+ test('setting a priority PATCHes the priority field, and unset is sent as null', async () => {
875
+ const r = renderTask({ ...BASE_TASK, priority: 5 }, { viewer: { rank: 'metic' } });
876
+ await r.boot(); await settle();
877
+ r.el('priority-select').value = '2';
878
+ await r.el('priority-btn')._handlers.click();
879
+ await settle();
880
+ const patchCall = r.writes.find((w) => w.method === 'PATCH');
881
+ assert.ok(patchCall, 'a PATCH was issued');
882
+ assert.equal(patchCall.body.priority, 2);
883
+
884
+ // '' is the UNSET option and must travel as null: "no priority" is a real state
885
+ // the optimizer sorts last (NULLS LAST), not a synonym for P5. Sending nothing
886
+ // would 400 as no_supported_fields.
887
+ const r2 = renderTask({ ...BASE_TASK, priority: 3 }, { viewer: { rank: 'metic' } });
888
+ await r2.boot(); await settle();
889
+ r2.el('priority-select').value = '';
890
+ await r2.el('priority-btn')._handlers.click();
891
+ await settle();
892
+ const clear = r2.writes.find((w) => w.method === 'PATCH');
893
+ assert.ok(clear && Object.prototype.hasOwnProperty.call(clear.body, 'priority'), 'priority is present');
894
+ assert.equal(clear.body.priority, null, 'and it is null, not "" and not 5');
895
+ });
896
+
897
+ test('re-setting the priority it already has sends nothing', async () => {
898
+ const r = renderTask({ ...BASE_TASK, priority: 3 }, { viewer: { rank: 'metic' } });
899
+ await r.boot(); await settle();
900
+ r.el('priority-select').value = '3';
901
+ await r.el('priority-btn')._handlers.click();
902
+ await settle();
903
+ assert.equal(r.writes.filter((w) => w.method === 'PATCH').length, 0, 'no write for a no-op');
904
+ assert.match(r.toasts.join(' '), /already P3/);
905
+ });
906
+
907
+ test('adding a dependency POSTs the id, and refuses a self-dependency before the network', async () => {
908
+ const r = renderTask(BASE_TASK, { viewer: { rank: 'metic' } });
909
+ await r.boot(); await settle();
910
+ r.el('dep-add-input').value = '4242';
911
+ await r.el('dep-add-btn')._handlers.click();
912
+ await settle();
913
+ const post = r.writes.find((w) => w.method === 'POST' && /\/dependencies$/.test(w.path));
914
+ assert.ok(post, 'a POST to /dependencies was issued');
915
+ assert.equal(post.body.depends_on_task_id, 4242);
916
+
917
+ const r2 = renderTask(BASE_TASK, { viewer: { rank: 'metic' } });
918
+ await r2.boot(); await settle();
919
+ r2.el('dep-add-input').value = String(BASE_TASK.id);
920
+ await r2.el('dep-add-btn')._handlers.click();
921
+ await settle();
922
+ assert.equal(r2.writes.length, 0, 'a self-dependency is caught before the request');
923
+ assert.match(r2.toasts.join(' '), /cannot wait for itself/i);
924
+ });
925
+
926
+ test('the × cuts an edge with DELETE, and only ever from "Blocked by"', async () => {
927
+ // The asymmetry is the point. DELETE /tasks/:id/dependencies/:depId removes the
928
+ // edge where THIS task waits on another — its own business. The "Blocks" list is
929
+ // the same edges from the far end: a × there would edit a DIFFERENT task's
930
+ // dependency set from a page not showing it.
931
+ const task = {
932
+ ...BASE_TASK,
933
+ dependencies: [{ id: 900, title: 'upstream', status: 'ready' }],
934
+ dependents: [{ id: 901, title: 'downstream', status: 'ready' }],
935
+ };
936
+ const r = renderTask(task, { viewer: { rank: 'metic' } });
937
+ await r.boot(); await settle();
938
+ const html = r.html();
939
+ assert.match(html, /data-cut-dep="900"/, 'the blocked-by edge is cuttable');
940
+ assert.doesNotMatch(html, /data-cut-dep="901"/, 'the blocks edge is NOT cuttable from here');
941
+
942
+ const xenos = renderTask(task, { viewer: { rank: 'xenos' } });
943
+ await xenos.boot(); await settle();
944
+ assert.doesNotMatch(xenos.html(), /data-cut-dep=/, 'and it is Metic+ only');
945
+ });
946
+
947
+ test('retire requires a reason, then POSTs it to /abandon', async () => {
948
+ const r = renderTask({ ...BASE_TASK, status: 'ready' }, { viewer: { rank: 'metic' } });
949
+ await r.boot(); await settle();
950
+ await r.el('abandon-open-btn')._handlers.click();
951
+ await settle();
952
+ assert.match(r.html(), /id="abandon-reason"/, 'the disclosure opens onto a reason field');
953
+
954
+ // A REASON IS REQUIRED though the server accepts an empty one: the value of the
955
+ // audit_log row is the sentence in it.
956
+ r.el('abandon-reason').value = ' ';
957
+ await r.el('abandon-btn')._handlers.click();
958
+ await settle();
959
+ assert.equal(r.writes.length, 0, 'a blank reason never reaches the server');
960
+ assert.match(r.toasts.join(' '), /Say why/i);
961
+
962
+ r.el('abandon-reason').value = 'superseded by the consolidated task';
963
+ await r.el('abandon-btn')._handlers.click();
964
+ await settle();
965
+ const post = r.writes.find((w) => /\/abandon$/.test(w.path));
966
+ assert.ok(post, 'a POST to /abandon was issued');
967
+ assert.equal(post.method, 'POST');
968
+ assert.equal(post.body.reason, 'superseded by the consolidated task');
969
+ assert.match(r.toasts.join(' '), /Promote restores it/i,
970
+ 'and the toast names the way back, so a misclick is visibly recoverable');
971
+ });
972
+
973
+ test('retire is replaced by a sentence where the server would only refuse it', async () => {
974
+ // Both of these are 409s the server raises anyway AND are knowable from this
975
+ // record. Offering a button that cannot work teaches the reader the page guesses.
976
+ const shipped = renderTask({ ...BASE_TASK, status: 'shipped' }, { viewer: { rank: 'metic' } });
977
+ await shipped.boot(); await settle();
978
+ assert.doesNotMatch(shipped.html(), /id="abandon-open-btn"/, 'no retire control on shipped');
979
+ assert.match(shipped.html(), /immutable history/i, 'it says why instead');
980
+
981
+ const held = renderTask({ ...BASE_TASK, status: 'active' }, {
982
+ viewer: { rank: 'metic' },
983
+ lastClaim: { github_login: 'someone', claimed_at: '2026-09-01T00:00:00Z', released_at: null },
984
+ });
985
+ await held.boot(); await settle();
986
+ assert.doesNotMatch(held.html(), /id="abandon-open-btn"/, 'no retire control while a claim is live');
987
+ assert.match(held.html(), /released/i, 'it names the step that unblocks it');
988
+ });
989
+
990
+ test("a shaping refusal surfaces the server's own sentence, verbatim", async () => {
991
+ const serverMsg = 'release the active claim before abandoning';
992
+ const r = renderTask({ ...BASE_TASK, status: 'ready' }, {
993
+ viewer: { rank: 'metic' },
994
+ write: { status: 409, data: { error: { code: 'task_has_active_claim', message: serverMsg } } },
995
+ });
996
+ await r.boot(); await settle();
997
+ await r.el('abandon-open-btn')._handlers.click();
998
+ await settle();
999
+ r.el('abandon-reason').value = 'no longer relevant';
1000
+ await r.el('abandon-btn')._handlers.click();
1001
+ await settle();
1002
+ assert.ok(r.toasts.some((m) => m.includes(serverMsg)),
1003
+ `the server's sentence is printed as-is — got ${JSON.stringify(r.toasts)}`);
1004
+ });
1005
+
824
1006
  test("a move-to-goal refusal (403) surfaces the server's own sentence, verbatim", async () => {
825
1007
  const task = { ...BASE_TASK, goal_id: 1000071 };
826
1008
  const goalsList = [{ id: 1000071, title: 'Old goal', status: 'open' }, { id: 555, title: 'New goal', status: 'open' }];