@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.
- package/.bongos-core.json +61 -41
- package/.claude/skills/planning-session/SKILL.md +2 -2
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +3 -1
- package/clients/bongos-client/index.cjs +3 -1
- package/clients/bongos-client/index.d.ts +6 -2
- package/clients/bongos-client/index.mjs +3 -1
- package/docs/adr/0157-archon-is-rank-and-identity-only.md +2 -0
- package/docs/api/openapi.json +103 -7
- package/docs/api-reference.md +4 -3
- package/docs/copy-inventory.md +135 -116
- package/docs/copy-registry.json +319 -138
- package/docs/module-api-changelog.md +4 -0
- package/modules/dev-box/app/src/vendor/bongos-client.cjs +3 -1
- package/modules/government/catalog.js +20 -11
- package/modules/government/migrations/government_012_version_create_archon.sql +45 -0
- package/modules/hall-ui/public/goals-render.js +182 -6
- package/modules/hall-ui/public/goals.css +51 -0
- package/modules/hall-ui/public/roadmap.css +5 -0
- package/modules/hall-ui/public/roadmap.js +25 -0
- package/modules/hall-ui/public/task-detail.js +22 -11
- package/modules/lifecycle/db-goals.js +34 -2
- package/modules/lifecycle/db-versions.js +195 -3
- package/modules/lifecycle/db.js +4 -1
- package/modules/lifecycle/routes/goals.js +15 -1
- package/modules/lifecycle/routes/version-route-authz.js +131 -1
- package/modules/lifecycle/routes/versions.js +79 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/triage.js +60 -26
- package/src/module-api.js +1 -1
- package/tests/goal_archive_hall.mjs +198 -0
- package/tests/government_require_permission.mjs +7 -3
- package/tests/government_seed.mjs +101 -9
- package/tests/promote_goal_id.mjs +85 -30
- package/tests/publish_reconciler.mjs +35 -5
- package/tests/task_detail_ui.mjs +36 -9
- package/tests/version_close_route.mjs +261 -0
- 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:
|
|
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
|
-
|
|
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
|
-
:
|
|
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
|
-
|
|
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
|
-
//
|
|
315
|
-
//
|
|
316
|
-
//
|
|
317
|
-
// it
|
|
318
|
-
//
|
|
319
|
-
|
|
320
|
-
|
|
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)
|
|
418
|
-
//
|
|
419
|
-
//
|
|
420
|
-
|
|
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)
|