@bongos/core 1.19.592 → 1.19.594

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.
Files changed (39) hide show
  1. package/.bongos-core.json +61 -41
  2. package/.claude/skills/planning-session/SKILL.md +2 -2
  3. package/clients/bongos-client/README.md +1 -1
  4. package/clients/bongos-client/bongos-client.global.js +3 -1
  5. package/clients/bongos-client/index.cjs +3 -1
  6. package/clients/bongos-client/index.d.ts +6 -2
  7. package/clients/bongos-client/index.mjs +3 -1
  8. package/docs/adr/0157-archon-is-rank-and-identity-only.md +2 -0
  9. package/docs/api/openapi.json +103 -7
  10. package/docs/api-reference.md +4 -3
  11. package/docs/copy-inventory.md +135 -116
  12. package/docs/copy-registry.json +319 -138
  13. package/docs/module-api-changelog.md +4 -0
  14. package/modules/dev-box/app/src/vendor/bongos-client.cjs +3 -1
  15. package/modules/government/catalog.js +20 -11
  16. package/modules/government/migrations/government_012_version_create_archon.sql +45 -0
  17. package/modules/hall-ui/public/goals-render.js +182 -6
  18. package/modules/hall-ui/public/goals.css +51 -0
  19. package/modules/hall-ui/public/roadmap.css +5 -0
  20. package/modules/hall-ui/public/roadmap.js +25 -0
  21. package/modules/hall-ui/public/task-detail.js +22 -11
  22. package/modules/lifecycle/db-goals.js +34 -2
  23. package/modules/lifecycle/db-versions.js +195 -3
  24. package/modules/lifecycle/db.js +4 -1
  25. package/modules/lifecycle/routes/goals.js +15 -1
  26. package/modules/lifecycle/routes/version-route-authz.js +131 -1
  27. package/modules/lifecycle/routes/versions.js +79 -1
  28. package/package-lock.json +2 -2
  29. package/package.json +1 -1
  30. package/scripts/gds/triage.js +60 -26
  31. package/src/module-api.js +1 -1
  32. package/tests/goal_archive_hall.mjs +198 -0
  33. package/tests/government_require_permission.mjs +7 -3
  34. package/tests/government_seed.mjs +101 -9
  35. package/tests/promote_goal_id.mjs +85 -30
  36. package/tests/publish_reconciler.mjs +35 -5
  37. package/tests/task_detail_ui.mjs +36 -9
  38. package/tests/version_close_route.mjs +261 -0
  39. package/tests/version_override_visibility.mjs +176 -0
@@ -1633,5 +1633,9 @@ is load-bearing: the script throws rather than guess if it is missing, and
1633
1633
  landed since 1.19.590 with no explicit bump. run 34181177013. (task 1002620)
1634
1634
  1.19.592 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1635
1635
  landed since 1.19.591 with no explicit bump. run 34181434332. (task 1002620)
1636
+ 1.19.593 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1637
+ landed since 1.19.592 with no explicit bump. run 34184095455. (task 1002620)
1638
+ 1.19.594 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1639
+ landed since 1.19.593 with no explicit bump. run 34184562228. (task 1002620)
1636
1640
  ---------------------------------------------------------------------------
1637
1641
  ```
@@ -890,8 +890,10 @@ function createClient(opts = {}) {
890
890
  "versions": {
891
891
  // GET /versions — rank: public — GET /versions
892
892
  getVersions: (args) => request("GET", "/versions", { hasBody: false }, args),
893
- // POST /versions — rank: metic+archon — POST /versions
893
+ // POST /versions — rank: archon — POST /versions
894
894
  postVersions: (args) => request("POST", "/versions", { hasBody: true }, args),
895
+ // POST /versions/{id}/close — rank: archon — POST /versions/:id/close
896
+ postVersionsIdClose: (args) => request("POST", "/versions/{id}/close", { hasBody: true }, args),
895
897
  // GET /versions/{id}/done-when — rank: public — GET /versions/:id/done-when
896
898
  getVersionsIdDoneWhen: (args) => request("GET", "/versions/{id}/done-when", { hasBody: false }, args),
897
899
  // POST /versions/{id}/done-when — rank: metic+archon — POST /versions/:id/done-when
@@ -101,6 +101,26 @@ const PERMISSIONS = [
101
101
  // enforced out of band and can never be delegated by a row in this database.
102
102
  { key: 'builder.rank.set', system: true, floor: 'archon', guards: 'PATCH /builders/:id/rank — the single rank writer' },
103
103
  { key: 'version.close', system: true, floor: 'archon', guards: 'version close disposition (never delegated below Archon)' },
104
+ // BV1.R21 (task 1003608, goal 1000086). ADR 0250 §6 amends ADR 0157 for exactly
105
+ // this key: `version.create` returns to the Archon floor.
106
+ //
107
+ // 0157 lowered it to metic on "authoring delegates, disposition does not" — creating
108
+ // a version confers no authority, so its siblings are `goal.create`/`task.create`.
109
+ // That was correct about what a version WAS. ADR 0250 D1 changed what a version IS:
110
+ // the boundary now carries the scope gate, so cutting a new version is no longer
111
+ // authoring — it is THE WAY AROUND the gate. A builder refused a goal on a building
112
+ // version needs only cut a fresh version to put it somewhere, and D1 evaporates with
113
+ // nobody overriding anything. Create therefore joins close as a disposition over
114
+ // scope, and sits beside it.
115
+ //
116
+ // STILL system:false, AND THAT LEAVES A REAL GAP — recorded rather than quietly
117
+ // widened. `system:true` marks trust-boundary power and permanently bars granting a
118
+ // key to any CUSTOM rank; false means the government may grant `version.create` to a
119
+ // custom sub-Archon rank, and for that rank D1 is decorative again. Raising the flag
120
+ // is a second-order change to what custom ranks may hold — the same reasoning that
121
+ // keeps `dependency.cross_tier.manage` where it is — and neither ADR 0250 nor this
122
+ // task decided it. Filed for the owner rather than taken here.
123
+ { key: 'version.create', system: false, floor: 'archon', guards: 'POST /versions — create a version + its done-when criteria (ADR 0250 §6 returns it to archon; ADR 0157 had lowered it to metic)' },
104
124
  { key: 'task.confirm.grade_bypass', system: true, floor: 'archon', guards: 'POST /tasks/:id/confirm — ratify past a failed grade (env-relaxable, ADR 0041)' },
105
125
  { key: 'dependency.cross_tier.manage', system: true, floor: 'archon', guards: 'POST|DELETE /dependencies — cross-tier structural edges (ADR 0086)' },
106
126
  { key: 'goal.scope.manage', system: true, floor: 'archon', guards: 'cross-tier goal-scope edges + protected-scope admission (ADR 0086)' },
@@ -179,17 +199,6 @@ const PERMISSIONS = [
179
199
  // be granted — a second-order change beyond the delegation that was asked for.
180
200
  { key: 'builder.budget.set', system: false, floor: 'metic', guards: 'PATCH /builders/:id/budget (ADR 0157, was archon)' },
181
201
  { key: 'criterion.unsatisfy', system: false, floor: 'metic', guards: 'POST /done-when/:id/unsatisfy (ADR 0157, was archon)' },
182
- // BV1.R105. system:false DELIBERATELY, despite `version.close` being system:true.
183
- // The `system` flag marks TRUST-BOUNDARY power — things that escalate or bypass the
184
- // boundary (deploy, protected-path, rank-set, grade-bypass, cross-tier, principals).
185
- // Creating a version is AUTHORING: it confers no authority on anyone. Its real
186
- // siblings are `goal.create` and `task.create`, both operational. Close is the
187
- // DISPOSITION that finalises a version's scope-truth; create opens it — the symmetry
188
- // argument was considered and rejected, because system:true would permanently bar
189
- // delegating version authoring to a custom rank for no trust-boundary reason.
190
- // ADR 0157 then lowered create's floor to metic while close stayed archon, which is
191
- // the same asymmetry read from the other side: authoring delegates, disposition does not.
192
- { key: 'version.create', system: false, floor: 'metic', guards: 'POST /versions — create a version + its done-when criteria (ADR 0157, was archon)' },
193
202
  { key: 'security.report.adjudicate', system: false, floor: 'metic', guards: 'GET /security/reports, POST /security/reports/:id/{confirm,dispute,fix,link-fix}, /security/docs CRUD (ADR 0157, was archon)' },
194
203
  { key: 'provisioning.fleet.manage', system: false, floor: 'metic', guards: 'GET /provisioning/fleet[/cost-ledger], POST .../force-teardown (ADR 0157, was archon)' },
195
204
  { key: 'box.fleet.manage', system: false, floor: 'metic', scope: 'any', resource: 'box', guards: 'GET /boxes[/cost-ledger], POST /boxes/:b/close, PATCH .../block (ADR 0157, was archon)' },
@@ -0,0 +1,45 @@
1
+ -- government_012_version_create_archon.sql — REVOKE `version.create` from the
2
+ -- Metic rank-role, completing BV1.R21 (task 1003608, goal 1000086, ADR 0250 §6).
3
+ --
4
+ -- THE DEFECT THIS CLOSES. R21 raised `version.create` back to the Archon floor in
5
+ -- `modules/government/catalog.js`, reversing the ADR 0157 delegation. But the
6
+ -- catalog is the DECLARATION; the grant rows are what `requirePermission` actually
7
+ -- reads. `governance_007_delegate_archon_ops_to_metic.sql` granted the key to
8
+ -- metic and nothing took it back, so on every already-migrated database — the live
9
+ -- instance included — a Metic still held `version.create` and the raised floor was
10
+ -- decorative. That is precisely the failure mode ADR 0016 exists to prevent: a
11
+ -- permission that markdown describes one way and the DB enforces another.
12
+ --
13
+ -- `tests/government_seed.mjs` caught it as "metic: grant migrations drift from
14
+ -- catalog RANK_SEED" — the union of the migrations yielded 66 keys for metic where
15
+ -- RANK_SEED yields 65 — and that red main blocked every merge in the repo until
16
+ -- this landed.
17
+ --
18
+ -- THE FIRST REVOKING MIGRATION IN THIS SYSTEM, and the guard said so in advance:
19
+ -- "A future migration that REVOKES (raising a floor) would make the union
20
+ -- overstate what the DB holds, so fail loudly here rather than let the guard
21
+ -- quietly go wrong." That was the right call and it worked. The guard now
22
+ -- SUBTRACTS revocations in file order, so the union keeps describing the effective
23
+ -- grant set rather than only the additive half of it.
24
+ --
25
+ -- NOT idempotent by ON CONFLICT — a DELETE needs none. It is naturally re-runnable:
26
+ -- deleting a row that is not there affects zero rows and raises nothing.
27
+ --
28
+ -- FORWARD-SAFETY, stated plainly because this one is not additive: an instance that
29
+ -- rolls the CODE back to a release whose catalog still floors `version.create` at
30
+ -- metic would find the grant gone, and its Metics would lose the ability to create
31
+ -- a version until they were re-granted. That is the correct direction to fail — a
32
+ -- rollback that silently re-widened a deliberately narrowed permission is the
33
+ -- worse outcome — and it is why raising a floor is a decision with an ADR behind
34
+ -- it rather than a config edit.
35
+ --
36
+ -- `government_rank_permissions` / `rank_key`, NOT the `governance_*` spelling:
37
+ -- government_001_rename_from_governance renamed both with no compatibility shim.
38
+
39
+ BEGIN;
40
+
41
+ DELETE FROM government_rank_permissions
42
+ WHERE rank_key = 'metic'
43
+ AND permission_key = 'version.create';
44
+
45
+ COMMIT;
@@ -175,10 +175,19 @@
175
175
  // task 1003276 — the category chip is a fact like the others, so it sits in
176
176
  // the facts line (a label a person scans, never a door — ADR 0192).
177
177
  const catHtml = categoryChip(g.category);
178
+ // BV1.R15 (task 1003602) — one chip, so a scan of the board shows WHICH goals
179
+ // were admitted mid-version without opening any of them. The who and the why
180
+ // are on the goal's own page (renderDetail), where there is room to read
181
+ // them; a list row only needs the yes/no, which is why `listGoals` carries a
182
+ // boolean and not the admission row.
183
+ const admittedChip = g.admitted
184
+ ? `<span class="fact-chip fact-chip--admitted" title="Added to this version after it started building — an Archon override of the scope gate. Open the goal to see who and why.">⚖ admitted</span>`
185
+ : '';
178
186
  const factsHtml = `<div class="gr-facts">`
179
187
  + `<span class="fact-pill fact-pill--${escapeHtml(g.status || 'open')}">${escapeHtml(g.status || 'open')}</span>`
180
188
  + chips.join('')
181
189
  + catHtml
190
+ + admittedChip
182
191
  + `</div>`;
183
192
  return { r, achieved, ringHtml, goalHref, right, catHtml, idHtml, stampHtml, whenHtml, factsHtml };
184
193
  }
@@ -614,14 +623,50 @@
614
623
  // only when the goal HAS criteria (an empty-criteria goal has nothing to be
615
624
  // unlinked from) and the count is >0 — silent otherwise, the goal_advisory
616
625
  // fail-open posture.
617
- function completionSummaryHtml(g, crit, sat, ringSize, criterionlessTaskCount) {
626
+ // heldOpenHtml BV1.R24 (task 1003611): the OTHER half of "why is this goal
627
+ // still open", which this page did not used to have to answer.
628
+ //
629
+ // Before R08/R09 a goal closed on its criteria alone, so the ring was the whole
630
+ // story. ADR 0250 D2 added "AND no task in it is non-terminal", and a task
631
+ // linked to no criterion holds the goal open while appearing nowhere in the
632
+ // rollup — so a builder could read 5-of-5 satisfied, watch the goal stay open,
633
+ // and have nothing on the page to explain it. Terminal is `shipped` or
634
+ // deliberately `abandoned`; `completed` and `confirmed` are mid-ship states that
635
+ // can still fail a grade, so they hold it open too, which is exactly the part
636
+ // people find surprising and the reason each task's status is shown.
637
+ //
638
+ // Silent when nothing is held — an empty "0 tasks" row on the overwhelmingly
639
+ // common goal is noise, and this band is read on every goal.
640
+ function heldOpenHtml(g, openTasks) {
641
+ if (!openTasks || !openTasks.total || g.status !== 'open') return '';
642
+ const shown = (openTasks.tasks || []).slice(0, 8);
643
+ const more = openTasks.total - shown.length;
644
+ const items = shown.map((t) =>
645
+ `<li><a href="#/task/${escapeHtml(String(t.id))}">#${escapeHtml(String(t.id))}</a>`
646
+ + ` <span class="held__title">${escapeHtml(t.title || '')}</span>`
647
+ + ` <span class="held__status">${escapeHtml(t.status || '')}</span></li>`).join('');
648
+ return `<div class="held-open">
649
+ <div class="held-open__head">${fmtNum(openTasks.total)} unfinished `
650
+ + `task${openTasks.total === 1 ? '' : 's'} ${openTasks.total === 1 ? 'is' : 'are'} holding this goal open</div>
651
+ <ul class="held-open__list">${items}</ul>
652
+ ${more > 0 ? `<div class="held-open__more">and ${fmtNum(more)} more</div>` : ''}
653
+ </div>`;
654
+ }
655
+ function completionSummaryHtml(g, crit, sat, ringSize, criterionlessTaskCount, openTasks) {
618
656
  const pending = crit.filter(isPending).length;
619
657
  // task 1002164 — an open goal used to punt confirmation to the /goal-review
620
658
  // CLI skill here; confirming now happens on this page, so the line reports
621
659
  // what is actually actionable instead of naming a command.
660
+ //
661
+ // BV1.R24: when work is what is holding the goal open, SAY SO here rather than
662
+ // repeating the criterion sentence. Satisfied criteria plus an open goal is
663
+ // precisely the state that reads as a bug when the page will not explain it.
664
+ const held = openTasks && openTasks.total ? openTasks.total : 0;
622
665
  const openLine = pending
623
666
  ? `${fmtNum(pending)} criteri${pending === 1 ? 'on is' : 'a are'} met and ready to confirm.`
624
- : 'Open — a criterion becomes confirmable once every task linked to it has shipped.';
667
+ : (held
668
+ ? `Open — every criterion can be satisfied and the goal still will not close while work is unfinished.`
669
+ : 'Open — a criterion becomes confirmable once every task linked to it has shipped.');
625
670
  const line = g.status === 'achieved' ? 'Goal achieved — every criterion confirmed.'
626
671
  : (g.status === 'archived' ? 'Archived.' : openLine);
627
672
  const unlinked = crit.length > 0 && criterionlessTaskCount > 0
@@ -635,7 +680,7 @@
635
680
  <div style="font-size:12.5px;color:var(--ink-faint)">${line}</div>
636
681
  ${unlinked}
637
682
  </div>
638
- </div>`;
683
+ </div>${heldOpenHtml(g, openTasks)}`;
639
684
  }
640
685
  function critListHtml(list, withDetail, canConfirm) {
641
686
  if (!list.length) return emptyStateHtml('No done-when criteria yet.');
@@ -666,11 +711,11 @@
666
711
  // task 1002164 — an archived/achieved goal has nothing left to confirm.
667
712
  const canConfirm = g.status === 'open' && canConfirmCriteria();
668
713
  const band = $('#goal-completion-body');
669
- if (band) band.innerHTML = completionSummaryHtml(g, crit, sat, 56, d.criterionless_task_count) + critListHtml(crit.slice(0, CRITERIA_CAP), false, canConfirm);
714
+ if (band) band.innerHTML = completionSummaryHtml(g, crit, sat, 56, d.criterionless_task_count, d.open_tasks) + critListHtml(crit.slice(0, CRITERIA_CAP), false, canConfirm);
670
715
  renderViewAllFoot('#goal-completion-foot', crit.length, CRITERIA_CAP, 'completion', 'criteria');
671
716
  setCount('#goal-completion-count', crit.length);
672
717
  const full = $('#goal-completion-full');
673
- if (full) full.innerHTML = completionSummaryHtml(g, crit, sat, 64, d.criterionless_task_count) + critListHtml(crit, true, canConfirm);
718
+ if (full) full.innerHTML = completionSummaryHtml(g, crit, sat, 64, d.criterionless_task_count, d.open_tasks) + critListHtml(crit, true, canConfirm);
674
719
  const back = $('#goal-completion-back');
675
720
  if (back && state.current != null) back.setAttribute('href', `#/goal/${state.current}`);
676
721
  }
@@ -804,7 +849,32 @@
804
849
  // task 1003276 — the human category, beside the machine-readable module
805
850
  // scope so the difference is visible: `scope` is the wall that refuses work
806
851
  // (ADR 0086); the chip is a word for a person and gates nothing (ADR 0192).
807
- meta.innerHTML = `<span>${escapeHtml(g.version_id || '')}</span> ${statusBadge(g.status)} ${categoryChip(g.category)} ${lockSpan} ${gateSpan} ${scope} ${depsSpan}`;
852
+ // BV1.R15 (task 1003602, goal 1000086, ADR 0250 §3) "the overrides are
853
+ // counted and shown on the version." This is the SHOWN half, at the one
854
+ // place the whole story fits: who waved this goal past the scope gate, and
855
+ // why. R03 refuses a goal on a version that is already building; an Archon
856
+ // may override, and that override is only honest if it is legible after
857
+ // the fact. `d.admitted` is null for every goal created normally, which is
858
+ // not missing data — it is the positive fact that this goal did not skip
859
+ // the gate, so nothing renders.
860
+ // The REASON is rendered, not tucked into a title= attribute: "by whom and
861
+ // why" is the whole ask, a tooltip is invisible on touch and to anyone not
862
+ // hunting for it, and an override whose justification you have to discover
863
+ // is the same unaccountable override with extra steps.
864
+ const adm = d.admitted;
865
+ const admBy = adm ? (adm.admitted_by_name || adm.admitted_by_login || ('builder ' + adm.admitted_by)) : '';
866
+ const admittedSpan = adm
867
+ ? `<span class="goal-badge goal-badge--admitted" title="This goal was added to ${escapeHtml(String(adm.version_id))} after it had already started building — an Archon override of the scope gate">⚖ Admitted by ${escapeHtml(admBy)}</span>`
868
+ : '';
869
+ meta.innerHTML = `<span>${escapeHtml(g.version_id || '')}</span> ${statusBadge(g.status)} ${categoryChip(g.category)} ${lockSpan} ${gateSpan} ${admittedSpan} ${scope} ${depsSpan}`;
870
+ if (adm) {
871
+ const why = document.createElement('div');
872
+ why.className = 'goal-admission';
873
+ why.innerHTML = `<strong>Admitted into ${escapeHtml(String(adm.version_id))} mid-build</strong> by ${escapeHtml(admBy)}`
874
+ + (adm.created_at ? ` on ${escapeHtml(fmtDate(adm.created_at))}` : '')
875
+ + (adm.reason ? ` — ${escapeHtml(String(adm.reason))}` : ' — no reason recorded.');
876
+ meta.appendChild(why);
877
+ }
808
878
  }
809
879
 
810
880
  // task 1762: the goal's own owner/manager (or an Archon) can invite/manage
@@ -846,6 +916,16 @@
846
916
  parts.push(`<button class="btn-ghost" id="goal-accepting-btn" type="button" data-next-accepting="${closing ? 'false' : 'true'}" title="${closing ? 'Stop taking join-requests; you can still invite people' : 'Take join-requests again'}">${closing ? 'Close to requests' : 'Open to requests'}</button>`);
847
917
  }
848
918
  }
919
+ // BV1.R24 (task 1003611): the archive two-step, without curl.
920
+ //
921
+ // Archiving used to be a single call that discarded whatever the goal held.
922
+ // R10 (task 1003597) made it refuse and return the work; R14 (task 1003601)
923
+ // made it apply a disposition. Both were API-only, so the only way to retire a
924
+ // goal holding work was to hand-write JSON — which is how the ADR 0264 goal cut
925
+ // ended up needing a terminal.
926
+ if (canManage && g.status === 'open') {
927
+ parts.push('<button class="btn-ghost" id="goal-archive-btn" type="button" title="Retire this goal — you will be asked what happens to any unfinished work">Archive goal</button>');
928
+ }
849
929
  if (g.status === 'open') {
850
930
  if (joined) {
851
931
  parts.push('<button class="btn-ghost" id="goal-leave-btn" type="button">Leave goal</button>');
@@ -871,6 +951,102 @@
871
951
  $('#goal-accepting-btn')?.addEventListener('click', (e) => toggleAcceptingRequests(g.id, e.currentTarget.dataset.nextAccepting === 'true'));
872
952
  $('#goal-request-btn')?.addEventListener('click', () => requestToJoin(g.id));
873
953
  $('#goal-withdraw-btn')?.addEventListener('click', () => withdrawRequest(g.id, state.myPendingRequest && state.myPendingRequest.id));
954
+ $('#goal-archive-btn')?.addEventListener('click', () => openArchivePanel(g));
955
+ }
956
+
957
+ // ---- BV1.R24: the archive two-step ---------------------------------------
958
+ //
959
+ // The panel mirrors the API exactly, because the API's shape IS the interaction:
960
+ // a goal holding nothing archives in one click; a goal holding work asks what
961
+ // happens to each task, and will not submit until every one has an answer.
962
+ //
963
+ // The client-side completeness check is a COURTESY, never the gate. The server
964
+ // refuses a partial map (`disposition_incomplete`) and the panel surfaces that
965
+ // refusal verbatim if it ever disagrees — a UI that believes it is the
966
+ // enforcement is how a second, drifting copy of a rule gets written.
967
+ function archiveRowHtml(t, openGoals) {
968
+ const opts = openGoals.map((g) =>
969
+ `<option value="${escapeHtml(String(g.id))}">move → #${escapeHtml(String(g.id))} ${escapeHtml(g.title || '')}</option>`).join('');
970
+ return `<li class="arch-row" data-task="${escapeHtml(String(t.id))}">
971
+ <span class="arch-row__task"><a href="#/task/${escapeHtml(String(t.id))}">#${escapeHtml(String(t.id))}</a>
972
+ ${escapeHtml(t.title || '')} <span class="held__status">${escapeHtml(t.status || '')}</span></span>
973
+ <select class="arch-row__verb" aria-label="What happens to task ${escapeHtml(String(t.id))}">
974
+ <option value="" selected>— decide —</option>
975
+ <option value="abandon">abandon</option>
976
+ ${opts}
977
+ </select>
978
+ </li>`;
979
+ }
980
+ async function openArchivePanel(g) {
981
+ const box = $('#goal-detail-actions');
982
+ if (!box) return;
983
+ const d = currentDetail();
984
+ const held = (d && d.open_tasks) || { total: 0, tasks: [] };
985
+ // Move targets: this version's other OPEN goals. A closed goal takes no new
986
+ // work (the server's goalNotOpenRefusal), and offering the goal being archived
987
+ // would be the silent discard renamed — both are filtered here so the options
988
+ // a person sees are the ones the server will accept.
989
+ let openGoals = [];
990
+ try {
991
+ const resp = await getJSON(`${API}/goals?version=${encodeURIComponent(g.version_id)}&limit=300`);
992
+ openGoals = (resp.goals || []).filter((o) => o.status === 'open' && !sameId(o.id, g.id));
993
+ } catch (_err) { openGoals = []; }
994
+
995
+ const panel = document.createElement('div');
996
+ panel.className = 'arch-panel';
997
+ panel.id = 'goal-archive-panel';
998
+ panel.innerHTML = held.total === 0
999
+ ? `<div class="arch-panel__head">Archive this goal?</div>
1000
+ <div class="arch-panel__note">It holds no unfinished work, so this is a single step. Archiving is reversible — an archived goal can be reopened.</div>
1001
+ <div class="arch-panel__foot">
1002
+ <input type="text" id="arch-reason" placeholder="Why (optional)" />
1003
+ <button class="btn-ghost" id="arch-cancel" type="button">Cancel</button>
1004
+ <button class="landing__btn" id="arch-go" type="button">Archive</button>
1005
+ </div>`
1006
+ : `<div class="arch-panel__head">${fmtNum(held.total)} unfinished task${held.total === 1 ? '' : 's'} — say what happens to each</div>
1007
+ <div class="arch-panel__note">Nothing is discarded by omission: every task needs a decision before this can be submitted. Abandoning stamps the reason below onto the task.</div>
1008
+ <ul class="arch-panel__list">${(held.tasks || []).map((t) => archiveRowHtml(t, openGoals)).join('')}</ul>
1009
+ ${held.total > (held.tasks || []).length ? `<div class="arch-panel__note">Showing ${fmtNum((held.tasks || []).length)} of ${fmtNum(held.total)}.</div>` : ''}
1010
+ <div class="arch-panel__foot">
1011
+ <input type="text" id="arch-reason" placeholder="Why this goal is being retired (required)" />
1012
+ <button class="btn-ghost" id="arch-cancel" type="button">Cancel</button>
1013
+ <button class="landing__btn" id="arch-go" type="button">Archive goal</button>
1014
+ </div>
1015
+ <div class="arch-panel__err" id="arch-err" hidden></div>`;
1016
+ box.appendChild(panel);
1017
+ $('#arch-cancel')?.addEventListener('click', () => panel.remove());
1018
+ $('#arch-go')?.addEventListener('click', () => submitArchive(g, held, panel));
1019
+ }
1020
+ async function submitArchive(g, held, panel) {
1021
+ const err = $('#arch-err');
1022
+ const say = (m) => { if (err) { err.hidden = false; err.textContent = m; } else { toast(m); } };
1023
+ const reason = String(($('#arch-reason') || {}).value || '').trim();
1024
+ const body = { };
1025
+ if (held.total > 0) {
1026
+ if (!reason) return say('Give a reason — it is recorded on every task this abandons.');
1027
+ const dispositions = {};
1028
+ const undecided = [];
1029
+ panel.querySelectorAll('.arch-row').forEach((row) => {
1030
+ const id = row.getAttribute('data-task');
1031
+ const v = row.querySelector('.arch-row__verb').value;
1032
+ if (!v) { undecided.push(id); return; }
1033
+ dispositions[id] = v === 'abandon' ? { verb: 'abandon' } : { verb: 'move', goal_id: Number(v) };
1034
+ });
1035
+ if (undecided.length) return say(`${undecided.length} task${undecided.length === 1 ? ' still needs' : 's still need'} a decision: ${undecided.map((i) => '#' + i).join(', ')}`);
1036
+ body.dispositions = dispositions;
1037
+ }
1038
+ if (reason) body.reason = reason;
1039
+ try {
1040
+ await postJSON(`${API}/goals/${encodeURIComponent(g.id)}/archive`, body);
1041
+ panel.remove();
1042
+ toast('Goal archived.');
1043
+ loadDetail(g.id, { force: true });
1044
+ } catch (e) {
1045
+ // The server's refusal is shown VERBATIM. It knows things this panel does
1046
+ // not — a task claimed since the page loaded, a move target that just
1047
+ // closed — and paraphrasing it would replace a precise answer with a guess.
1048
+ say((e && e.message) || 'Could not archive that goal.');
1049
+ }
874
1050
  }
875
1051
  async function loadActiveClaims() {
876
1052
  try {
@@ -205,6 +205,20 @@
205
205
  /* task 1953 — private goals: the lock badge (meta line) + the goal's own
206
206
  pending invite/join-request queue (manager view, next to the invite box). */
207
207
  .goal-badge--locked { background: var(--warn-weak); color: var(--warn); }
208
+
209
+ /* BV1.R15 (task 1003602, goal 1000086, ADR 0250 §3) — a goal admitted into a
210
+ version that was already building. Warn-weighted like the lock badge on
211
+ purpose: this is the scope gate having been OVERRIDDEN, and it should read as
212
+ an exception rather than as decoration. The reason line under it is quiet
213
+ (it is prose, not a status), but it is rendered rather than hidden in a
214
+ tooltip — an override you have to hover to justify is not accountable. */
215
+ .goal-badge--admitted { background: var(--warn-weak); color: var(--warn); }
216
+ .fact-chip--admitted { background: var(--warn-weak); color: var(--warn); border-color: transparent; }
217
+ .goal-admission {
218
+ flex-basis: 100%; margin: 4px 0 0; font-size: 12px; line-height: 1.5;
219
+ color: var(--ink-faint);
220
+ }
221
+ .goal-admission strong { color: var(--warn); font-weight: 500; }
208
222
  .goal-pending { margin: 4px 0 10px; display: flex; flex-direction: column; gap: 10px; }
209
223
  .pending-group__h {
210
224
  font-family: var(--label-face); font-size: var(--label-size); font-weight: 400;
@@ -417,3 +431,40 @@
417
431
  @media (prefers-reduced-motion: reduce) {
418
432
  .claude-text__toggle::before { transition: none; }
419
433
  }
434
+
435
+ /* ---- BV1.R24 (task 1003611): what holds a goal open, and the archive two-step.
436
+ Two surfaces the goal page never needed until ADR 0250 D2 made a goal's closure
437
+ depend on its WORK as well as its criteria. Both use the page's own hairline +
438
+ ink tokens rather than colours of their own — a panel that announces itself is
439
+ a panel people click by accident. */
440
+
441
+ .held-open {
442
+ margin-top: 10px; padding-top: 10px;
443
+ border-top: var(--line) solid var(--rule-soft);
444
+ }
445
+ .held-open__head { font-size: 12.5px; font-weight: 600; color: var(--ink); margin-bottom: 6px; }
446
+ .held-open__list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 3px; }
447
+ .held-open__list li { font-size: 12.5px; color: var(--ink-faint); }
448
+ .held__title { color: var(--ink); }
449
+ /* The status is the surprising part — `completed` and `confirmed` still hold a
450
+ goal open — so it is set apart rather than run into the title. */
451
+ .held__status {
452
+ font-size: 11px; font-variant-numeric: tabular-nums;
453
+ padding: 0 5px; border-radius: 3px;
454
+ border: var(--line) solid var(--rule-soft); color: var(--ink-faint);
455
+ }
456
+ .held-open__more { font-size: 11.5px; color: var(--ink-faint); margin-top: 4px; }
457
+
458
+ .arch-panel {
459
+ flex-basis: 100%; margin-top: 10px; padding: 12px;
460
+ border: var(--line) solid var(--rule); border-radius: 8px;
461
+ }
462
+ .arch-panel__head { font-weight: 650; font-size: 13.5px; margin-bottom: 4px; }
463
+ .arch-panel__note { font-size: 12px; color: var(--ink-faint); margin-bottom: 8px; }
464
+ .arch-panel__list { list-style: none; margin: 0 0 10px; padding: 0; display: flex; flex-direction: column; gap: 6px; }
465
+ .arch-row { display: flex; align-items: center; gap: 10px; font-size: 12.5px; }
466
+ .arch-row__task { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
467
+ .arch-row__verb { flex: none; font-size: 12px; }
468
+ .arch-panel__foot { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
469
+ .arch-panel__foot input { flex: 1; min-width: 200px; }
470
+ .arch-panel__err { margin-top: 8px; font-size: 12.5px; color: var(--danger); }
@@ -200,6 +200,11 @@ a.rm-goal__t:hover { color:var(--accent-ink); text-decoration:underline; }
200
200
  .rm-gmore:hover { text-decoration:underline; }
201
201
  .rm-gmore svg { width:13px; height:13px; }
202
202
  .rm-gnone { font-size:12.5px; color:var(--ink-faint); margin:2px 0 0; }
203
+ /* BV1.R15 (task 1003602) — the scope-gate override count. Warn-coloured because
204
+ it reports an exception being used, and only ever rendered when the count is
205
+ above zero (see admissionNote). */
206
+ .rm-gadm { font-size:12.5px; color:var(--warn); margin:10px 0 0; }
207
+ .rm-gadm b { font-weight:600; }
203
208
 
204
209
  /* parked note + staged state */
205
210
  .rm-parknote { display:flex; gap:9px; font-size:12.5px; color:var(--ink-soft); background:var(--bg-card);
@@ -85,6 +85,25 @@
85
85
 
86
86
  const arrowSvg = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 12h14M13 6l6 6-6 6"/></svg>';
87
87
 
88
+ // BV1.R15 (task 1003602, goal 1000086, ADR 0250 §3): "the overrides are
89
+ // counted and shown on the version."
90
+ //
91
+ // RENDERS NOTHING AT ZERO, and that is the design rather than a shortcut. A
92
+ // permanent "0 overrides" line is chrome a reader learns to skip, and the
93
+ // moment it becomes "2 overrides" it would go on being skipped. Appearing only
94
+ // when the gate was actually overridden makes the line itself the signal.
95
+ //
96
+ // It names no goals: which ones they are is one click away on the goals board
97
+ // (they carry an "admitted" chip), and a roadmap card that grew a second list
98
+ // stops being a roadmap.
99
+ function admissionNote(v) {
100
+ const n = Number(v && v.admissions) || 0;
101
+ if (n === 0) return '';
102
+ return `<p class="rm-gadm" title="A goal may only be added to a version that is still in planning. These were added after this version started building — each needed an Archon to override the scope gate and record a reason.">`
103
+ + `⚖ <b>${fmtNum(n)}</b> goal${n === 1 ? '' : 's'} admitted after this version started building`
104
+ + `</p>`;
105
+ }
106
+
88
107
  // The goals block that sits inside an in-flight / featured card. `active` are
89
108
  // the in-progress goals (sat>0), already sorted; we show up to 4 and let
90
109
  // "See all" carry the rest.
@@ -115,6 +134,7 @@
115
134
  </div>
116
135
  ${active.length ? `<div class="rm-ginp"><div class="rm-ginp__cap">Active right now</div>${rows}</div>`
117
136
  : '<p class="rm-gnone" style="margin-top:12px">No goal has criteria met yet — work is in the task queue.</p>'}
137
+ ${admissionNote(v)}
118
138
  <a class="rm-gmore" href="${GOALS_URL}">See all ${g.total} goals ${arrowSvg}</a>
119
139
  </div>`;
120
140
  }
@@ -350,6 +370,11 @@
350
370
  total: Number(p.total_count) || 0,
351
371
  pct: Number(p.percent_complete) || 0,
352
372
  criteria: Number(p.criteria_count) || 0,
373
+ // BV1.R15 (task 1003602, goal 1000086, ADR 0250 §3) — how many goals were
374
+ // admitted into this version AFTER it started building, i.e. how many
375
+ // times the scope gate was overridden. 0 for every version nobody
376
+ // widened, which is the answer this line should almost always give.
377
+ admissions: Number(p.admission_count) || 0,
353
378
  life: {
354
379
  sh: Number(p.lifecycle_shipped_count) || 0,
355
380
  co: Number(p.lifecycle_confirmed_count) || 0,
@@ -311,13 +311,18 @@
311
311
  opts.push('<option value="' + esc(String(g.id)) + '"' + (hint == null && i === 0 ? ' selected' : '') + '>'
312
312
  + esc(String(g.id)) + ' — ' + esc(g.title || '') + (why ? ' (matched: ' + esc(why) + ')' : '') + '</option>');
313
313
  });
314
- // Always offer the opt-out, and make it the fallback when nothing was found.
315
- // Sending no goal_id does not create a goal-less task db.createTask files
316
- // it under the version's catch-all "general" goal so the label names where
317
- // it actually lands. That bucket carries no done-when criteria, which is why
318
- // it should be a choice rather than the silent default it used to be.
319
- opts.push('<option value=""' + (opts.length === 0 ? ' selected' : '')
320
- + '>— none (the version\'s general bucket) —</option>');
314
+ // BV1.R22 (task 1003609): THE OPT-OUT IS GONE, because the bucket it named is.
315
+ // R11 (task 1003598) deleted db.createTask's catch-all fallback and made
316
+ // goal_id required, so an empty selection is no longer "the general bucket"
317
+ // it is a server 400 landing on a builder who has already filled the form.
318
+ //
319
+ // What replaces it is a PLACEHOLDER, not a value: disabled so it cannot be
320
+ // submitted, and selected only when nothing else was found, so the select is
321
+ // never blank-but-valid-looking. A builder with no suggestions is told what to
322
+ // do rather than handed a choice that fails.
323
+ if (opts.length === 0) {
324
+ opts.push('<option value="" selected disabled>— no goal suggested — open the goal board and pick one —</option>');
325
+ }
321
326
  return opts.join('');
322
327
  }
323
328
 
@@ -414,10 +419,16 @@
414
419
  source: 'idea_inbox',
415
420
  source_ref: 'idea:' + idea.id,
416
421
  };
417
- // task 1003071 (R13): the whole point — a triaged promotion NAMES its goal.
418
- // Empty means the operator chose the version's general bucket, which the
419
- // server applies itself when goal_id is absent.
420
- if (form.goal && form.goal.value !== '') payload.goal_id = Number(form.goal.value);
422
+ // task 1003071 (R13) made a triaged promotion name its goal; BV1.R22 (task
423
+ // 1003609) makes it required. There is no longer a version catch-all to fall
424
+ // into, so an empty select is refused HERE — before two writes and a round
425
+ // trip rather than coming back as a server 400 the form cannot explain.
426
+ if (!form.goal || form.goal.value === '') {
427
+ busy(false, 'Create task');
428
+ note('Pick a goal before promoting — every task belongs to one. If none of the suggestions fit, open the goal board and choose the right goal first.');
429
+ return;
430
+ }
431
+ payload.goal_id = Number(form.goal.value);
421
432
  if (form.est.value !== '') payload.est_minutes = Number(form.est.value);
422
433
  if (form.credits.value !== '') payload.credits_reward = Number(form.credits.value);
423
434
  // 'criterion-proposal' is an idea-only kind; everything else is shared with
@@ -178,12 +178,22 @@ async function ensureMaintenanceGoal(versionId, deps = {}) {
178
178
  // version" — a visible counter is what keeps the exception honest, because an
179
179
  // override nobody can see is just a slower version of having no rule. Returns []
180
180
  // for the overwhelmingly common case of a version whose scope nobody widened.
181
+ // BV1.R15 (task 1003602) carries the ADMITTER'S NAME, not just their id. R07
182
+ // returned a bare `admitted_by`, which is enough to count an override and not
183
+ // enough to answer the question ADR 0250 §3 actually asks — "by whom" — without
184
+ // the caller issuing a second lookup per row. The whole point of the counter is
185
+ // that abuse is legible at a glance; a column of numeric ids is not legible.
186
+ // LEFT JOIN, not JOIN: an admission whose admitter was later offboarded must
187
+ // still appear in the count. Losing the row would be the one failure mode that
188
+ // matters, since it hides exactly the history someone would want to erase.
181
189
  async function listVersionAdmissions(versionId, deps = {}) {
182
190
  const activePool = (deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : pool;
183
191
  const { rows } = await activePool.query(
184
- `SELECT a.goal_id, a.version_id, a.admitted_by, a.reason, a.created_at, g.title AS goal_title
192
+ `SELECT a.goal_id, a.version_id, a.admitted_by, a.reason, a.created_at, g.title AS goal_title,
193
+ b.github_login AS admitted_by_login, b.display_name AS admitted_by_name
185
194
  FROM lifecycle_goal_version_admissions a
186
195
  JOIN goals g ON g.id = a.goal_id
196
+ LEFT JOIN builders b ON b.id = a.admitted_by
187
197
  WHERE a.version_id = $1
188
198
  ORDER BY a.created_at DESC`,
189
199
  [versionId]
@@ -191,6 +201,7 @@ async function listVersionAdmissions(versionId, deps = {}) {
191
201
  return rows;
192
202
  }
193
203
 
204
+
194
205
  // getGoalAdmission (BV1.R07, task 1003594) — the override record for ONE goal, or
195
206
  // null when the goal was created normally. Null is the overwhelmingly common
196
207
  // answer and is not an absence of data: it is the positive fact "this goal did
@@ -244,10 +255,31 @@ async function getGoalDependencies(goalId, deps = {}) {
244
255
  // A caller that only needs the first N (the orphan advisory wants 5 candidates)
245
256
  // passes it so Postgres does the truncating instead of shipping the whole version's
246
257
  // goals to be discarded in JS (task 1002539).
258
+ // BV1.R15 (task 1003602, goal 1000086, ADR 0250 §3) adds `admitted` — did this
259
+ // goal enter its version through the Archon override rather than the scope gate?
260
+ //
261
+ // A BOOLEAN, not the admission row. The board renders one chip; the who and the
262
+ // why belong on the goal's own page, where there is room to read them, and
263
+ // shipping the reason on every row of a list would put an Archon's free text
264
+ // through a surface that only needs a yes/no. `getGoalAdmission` is still the
265
+ // read for the detail.
266
+ //
267
+ // EXISTS rather than a LEFT JOIN: one row per goal is guaranteed either way, and
268
+ // a join that later gained a second admission row per goal would silently
269
+ // duplicate goals in the board's list.
270
+ //
271
+ // The correlation names `goals.id` in full rather than aliasing the table, so
272
+ // every other line of this query is UNCHANGED — the column list, the two filters
273
+ // and the ORDER BY all still read unqualified. That is deliberate: aliasing the
274
+ // table to `g` would have been tidier and would have rewritten four lines whose
275
+ // literal text tests/goal_tier.mjs pins ("priority-first, then stable
276
+ // ordering"). An additive change should read as additive in the diff.
247
277
  async function listGoals({ versionId = null, status = null, limit = null } = {}, deps = {}) {
248
278
  const activePool = (deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : pool;
249
279
  const { rows } = await activePool.query(
250
- `SELECT ${GOAL_COLS}
280
+ `SELECT ${GOAL_COLS},
281
+ EXISTS (SELECT 1 FROM lifecycle_goal_version_admissions a
282
+ WHERE a.goal_id = goals.id) AS admitted
251
283
  FROM goals
252
284
  WHERE ($1::text IS NULL OR version_id = $1)
253
285
  AND ($2::text IS NULL OR status = $2)