@bongos/core 1.19.642 → 1.19.643
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 +21 -21
- package/docs/copy-inventory.md +114 -114
- package/docs/copy-registry.json +127 -127
- package/docs/module-api-changelog.md +2 -0
- package/modules/builder-settings/builder-needs.js +14 -0
- package/modules/hall-ui/public/hall-render.js +5 -0
- package/modules/hall-ui/public/shell.js +95 -5
- package/modules/hall-ui/public/style.css +38 -0
- package/modules/lifecycle/rot.js +51 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/claude-materialize.js +83 -5
- package/src/module-api.js +1 -1
- package/tests/claude_materialize.mjs +95 -0
- package/tests/government_board_summons.mjs +19 -0
- package/tests/hall_nav.mjs +150 -1
- package/tests/rot_inbox.mjs +79 -3
|
@@ -1733,5 +1733,7 @@ is load-bearing: the script throws rather than guess if it is missing, and
|
|
|
1733
1733
|
landed since 1.19.640 with no explicit bump. run 34506718627. (task 1002620)
|
|
1734
1734
|
1.19.642 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1735
1735
|
landed since 1.19.641 with no explicit bump. run 34507442448. (task 1002620)
|
|
1736
|
+
1.19.643 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1737
|
+
landed since 1.19.642 with no explicit bump. run 34509123612. (task 1002620)
|
|
1736
1738
|
---------------------------------------------------------------------------
|
|
1737
1739
|
```
|
|
@@ -121,6 +121,14 @@ function helpRequestsNeed(ctx) {
|
|
|
121
121
|
|
|
122
122
|
// The registry. To add a future input the system needs from a builder, append a
|
|
123
123
|
// pure (ctx) => need|null function here — every renderer picks it up for free.
|
|
124
|
+
//
|
|
125
|
+
// A need MAY carry `count: <positive integer>` (task 1003737) when the thing
|
|
126
|
+
// waiting is countable. That is what a nav badge renders, and it is the whole
|
|
127
|
+
// contract: the shell names a need id and reads this number, so a second
|
|
128
|
+
// consumer — open help requests, the idea inbox — adopts the badge by adding
|
|
129
|
+
// this one field rather than inventing a second notification mechanism.
|
|
130
|
+
// The count is ADVISORY DECORATION, never authority: the roster's own ordering
|
|
131
|
+
// (NEED_PRIORITY) and each surface's gate decide what a builder actually sees.
|
|
124
132
|
// Open task RECOMMENDATIONS waiting on this builder (task 1003173 / C03, goal
|
|
125
133
|
// 1000075; criterion collab-pass-a-task). Sibling of helpRequestsNeed above, and
|
|
126
134
|
// here for the same reason C01's grader round put that one here: a recommendation
|
|
@@ -184,6 +192,12 @@ function boardVotesNeed(ctx) {
|
|
|
184
192
|
const subject = n === 1 ? 'An idea is' : `${n} ideas are`;
|
|
185
193
|
return {
|
|
186
194
|
id: 'board_votes',
|
|
195
|
+
// The number, machine-readable (task 1003737). The message has always
|
|
196
|
+
// carried it in prose, which a sentence can render and a BADGE cannot. A
|
|
197
|
+
// need that is inherently countable states its count here; a need that is
|
|
198
|
+
// not (art_key — a key is set or it is not) simply omits it, and every
|
|
199
|
+
// renderer treats an absent count as "nothing to put in a badge".
|
|
200
|
+
count: n,
|
|
187
201
|
title: n === 1 ? 'An idea is waiting on your vote' : 'Ideas are waiting on your vote',
|
|
188
202
|
what: 'You sit on the board, and a Full Idea that cleared the completeness grade needs a decision.',
|
|
189
203
|
state: 'action_needed',
|
|
@@ -216,6 +216,11 @@
|
|
|
216
216
|
// the same reason: without it the index — the hall's busiest page — is the
|
|
217
217
|
// one page a permission-gated nav item would never appear on.
|
|
218
218
|
permissions: (ctx.lastMeState && ctx.lastMeState.permissions) || null,
|
|
219
|
+
// task 1003737: the needs roster, for the nav counts. Read off
|
|
220
|
+
// ctx.lastMeState for the same reason the two above are, and passed for the
|
|
221
|
+
// same reason: the index is where a builder lands, so omitting it here
|
|
222
|
+
// would mean the badge appeared everywhere EXCEPT the page they open first.
|
|
223
|
+
needs: (ctx.lastMeState && ctx.lastMeState.needs) || null,
|
|
219
224
|
builder,
|
|
220
225
|
});
|
|
221
226
|
|
|
@@ -129,6 +129,23 @@
|
|
|
129
129
|
// stays hidden — deliberately, since an unresolvable gate is a gate
|
|
130
130
|
// that cannot be vouched for.
|
|
131
131
|
// `view` marks an index view (hash-routed); `module` gates on window.__MODULES__.
|
|
132
|
+
//
|
|
133
|
+
// `count` (task 1003737) names a NEED ID in the `needs` roster `/me` already
|
|
134
|
+
// returns; the item then carries a badge with that need's `count`. It is the
|
|
135
|
+
// hall's only badge mechanism and it is deliberately generic — a second
|
|
136
|
+
// consumer (open help requests, the idea inbox) adds `count: '<need id>'` here
|
|
137
|
+
// and a `count` field to its need, and inherits the rest.
|
|
138
|
+
//
|
|
139
|
+
// THREE RULES THE MECHANISM ENFORCES, not the caller:
|
|
140
|
+
// ZERO IS SILENT — no badge, no empty ring. A badge means something is
|
|
141
|
+
// genuinely waiting, so an absent need, a zero/absent count, or a need the
|
|
142
|
+
// founding grace has quieted out of 'action_needed' all render nothing.
|
|
143
|
+
// IT NEVER OUT-RUNS THE GATE — the badge is written only onto an item this
|
|
144
|
+
// viewer can already see, and cleared from one they cannot, so a count can
|
|
145
|
+
// never disclose activity in a room that is hidden from them.
|
|
146
|
+
// IT SPEAKS — the numeral is aria-hidden and the need's own `title` rides
|
|
147
|
+
// alongside it as sr-only text, because "3" alone in a link tells a screen
|
|
148
|
+
// reader nothing about what is waiting.
|
|
132
149
|
// task 2175: grouped by AUDIENCE of the data, so the sidebar reads in coherent
|
|
133
150
|
// runs instead of one long list — the project (unlabeled), You, Community,
|
|
134
151
|
// Docs, Government. A group's `gate` must match its most permissive item's, or the
|
|
@@ -168,7 +185,12 @@
|
|
|
168
185
|
// are unchanged.
|
|
169
186
|
//
|
|
170
187
|
// Cosmetic only, as every gate here is — the page gate is the wall (ADR 0016).
|
|
171
|
-
|
|
188
|
+
// task 1003737: the badge's first consumer. The owner's ask was to see that
|
|
189
|
+
// a sitting waits WITHOUT opening the room, and the count is already on
|
|
190
|
+
// /me (boardVotesNeed), pre-filtered to what the vote route would accept —
|
|
191
|
+
// so the badge counts ballots this viewer can actually cast, never "items
|
|
192
|
+
// open somewhere".
|
|
193
|
+
{ id: 'board-room', label: 'Board Room', icon: 'admin', href: '/board-room', gate: 'perm:board.vote.cast', count: 'board_votes' },
|
|
172
194
|
// task 1003113 (R02 of goal 1000074): the artist's queue over the words the
|
|
173
195
|
// product shows people. gate 'signed' and NOT a craft gate — filing a flag
|
|
174
196
|
// is open to every role on purpose (the engineer who wrote the toast and
|
|
@@ -344,8 +366,24 @@
|
|
|
344
366
|
(it.external ? ' target="_blank" rel="noopener"' : '') +
|
|
345
367
|
(it.view ? ' data-view="' + it.view + '"' : '') +
|
|
346
368
|
' data-gate="' + it.gate + '"' + (it.module ? ' data-module="' + it.module + '"' : '') +
|
|
369
|
+
(it.count ? ' data-count="' + esc(it.count) + '"' : '') +
|
|
347
370
|
' title="' + esc(it.label) + '" hidden>' + icon(it.icon) +
|
|
348
|
-
'<span class="nav-item__label">' + esc(it.label) + '</span
|
|
371
|
+
'<span class="nav-item__label">' + esc(it.label) + '</span>' +
|
|
372
|
+
// task 1003737: the badge ships EMPTY and hidden, and applyCounts fills
|
|
373
|
+
// it. Rendered here rather than injected later so the anchor's shape is
|
|
374
|
+
// fixed at first paint — a badge appended after the /me round-trip
|
|
375
|
+
// would reflow the rail under the reader's cursor.
|
|
376
|
+
//
|
|
377
|
+
// Two nodes, because they address two audiences: the numeral is
|
|
378
|
+
// aria-hidden decoration for the eye, and the sr-only span carries the
|
|
379
|
+
// need's own sentence for a screen reader. One node cannot do both —
|
|
380
|
+
// "3" read aloud inside a link named Board Room says nothing about what
|
|
381
|
+
// the 3 is.
|
|
382
|
+
(it.count
|
|
383
|
+
? '<span class="nav-item__badge" data-badge aria-hidden="true" hidden></span>'
|
|
384
|
+
+ '<span class="sr-only" data-badge-label></span>'
|
|
385
|
+
: '') +
|
|
386
|
+
'</a>';
|
|
349
387
|
}).join('');
|
|
350
388
|
// task 1003303: aria-controls names the region the head discloses. The
|
|
351
389
|
// frozen markup carried aria-expanded pointing at nothing, which is an
|
|
@@ -559,7 +597,7 @@
|
|
|
559
597
|
// not establish authority. null is NOT "holds nothing that matters": it is
|
|
560
598
|
// unreadable, and unreadable hides the link, the same call gateAllows()'s
|
|
561
599
|
// `default: false` and the social mode's OFF-is-the-fallback rule make.
|
|
562
|
-
var access = { authed: null, rank: null, graduated: false, showWork: false, permissions: null };
|
|
600
|
+
var access = { authed: null, rank: null, graduated: false, showWork: false, permissions: null, needs: null };
|
|
563
601
|
|
|
564
602
|
function gateAllows(gate) {
|
|
565
603
|
var authed = access.authed === true;
|
|
@@ -626,8 +664,47 @@
|
|
|
626
664
|
el.classList.toggle('nav-item--craft', isArtist);
|
|
627
665
|
}
|
|
628
666
|
|
|
629
|
-
//
|
|
630
|
-
//
|
|
667
|
+
// task 1003737: the nav badge mechanism. Reads the `needs` roster `/me`
|
|
668
|
+
// already returns and writes the count onto every item that declared one.
|
|
669
|
+
//
|
|
670
|
+
// WHY IT RUNS INSIDE applyAccess AND AFTER THE GATE LOOP, not beside it: the
|
|
671
|
+
// gate loop is the thing that decides whether this viewer may see the item at
|
|
672
|
+
// all, so reading `el.hidden` afterwards is reading the answer rather than
|
|
673
|
+
// re-deriving it — the same discipline task 1003744 applied to `permissions`
|
|
674
|
+
// (a surface that decides for itself can disagree with the rest of the hall).
|
|
675
|
+
// A hidden item is CLEARED rather than skipped: skipping would leave a stale
|
|
676
|
+
// count in the DOM of someone who just lost access to the room.
|
|
677
|
+
//
|
|
678
|
+
// Silence has four causes and they are deliberately indistinguishable to the
|
|
679
|
+
// reader: no needs payload yet (the /me round-trip is still out), no such need,
|
|
680
|
+
// a need the founding grace has moved out of 'action_needed', or a count of
|
|
681
|
+
// zero. In every one of them the honest badge is no badge.
|
|
682
|
+
function applyCounts() {
|
|
683
|
+
var needs = (access.needs && Array.isArray(access.needs.items)) ? access.needs.items : [];
|
|
684
|
+
document.querySelectorAll('#app-sidebar .nav-item[data-count]').forEach(function (el) {
|
|
685
|
+
var badge = el.querySelector('[data-badge]');
|
|
686
|
+
var label = el.querySelector('[data-badge-label]');
|
|
687
|
+
if (!badge || !label) return;
|
|
688
|
+
var need = null;
|
|
689
|
+
for (var i = 0; i < needs.length; i++) {
|
|
690
|
+
if (needs[i] && needs[i].id === el.dataset.count) { need = needs[i]; break; }
|
|
691
|
+
}
|
|
692
|
+
// 'action_needed' is the gate on LOUDNESS: the founding grace rewrites a
|
|
693
|
+
// demanding need to 'covered' precisely so a newborn instance does not
|
|
694
|
+
// greet its owner with demands, and a badge is a demand.
|
|
695
|
+
var n = (need && need.state === 'action_needed') ? Number(need.count) : 0;
|
|
696
|
+
var show = !el.hidden && Number.isFinite(n) && n > 0;
|
|
697
|
+
badge.hidden = !show;
|
|
698
|
+
badge.textContent = show ? (n > 99 ? '99+' : String(n)) : '';
|
|
699
|
+
// The need's own title, which is already a sentence written for a person
|
|
700
|
+
// ("Ideas are waiting on your vote"). Falling back to a bare count would
|
|
701
|
+
// reintroduce exactly the floating numeral this avoids.
|
|
702
|
+
label.textContent = show ? (need.title || (n + ' waiting')) : '';
|
|
703
|
+
});
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
// applyAccess({ authed, rank, graduated, showWork, social, needs }) — settle nav
|
|
707
|
+
// visibility, the social mode, and the nav counts.
|
|
631
708
|
// Called by builders.js on the index (it owns /me there) and by the shell's own
|
|
632
709
|
// probe elsewhere. Cosmetic only; the server enforces (ADR 0016).
|
|
633
710
|
function applyAccess(state) {
|
|
@@ -643,6 +720,11 @@
|
|
|
643
720
|
if ('permissions' in state) {
|
|
644
721
|
access.permissions = (state.permissions && typeof state.permissions === 'object') ? state.permissions : null;
|
|
645
722
|
}
|
|
723
|
+
// task 1003737: the needs roster, for the nav counts. Stored as given —
|
|
724
|
+
// null means "the shell has not been told yet", which reads as no badge.
|
|
725
|
+
if ('needs' in state) {
|
|
726
|
+
access.needs = (state.needs && typeof state.needs === 'object') ? state.needs : null;
|
|
727
|
+
}
|
|
646
728
|
if ('social' in state) applySocial(state.social);
|
|
647
729
|
if (state.builder) { renderUser(state.builder); applyCraftEmphasis(state.builder); }
|
|
648
730
|
}
|
|
@@ -651,6 +733,10 @@
|
|
|
651
733
|
if (ok && el.dataset.module && !moduleOn(el.dataset.module)) ok = false;
|
|
652
734
|
el.hidden = !ok;
|
|
653
735
|
});
|
|
736
|
+
// AFTER the gate loop: applyCounts reads el.hidden, which the loop above is
|
|
737
|
+
// what settles. Reversing the order would badge an item still hidden from
|
|
738
|
+
// the previous state.
|
|
739
|
+
applyCounts();
|
|
654
740
|
var signin = document.getElementById('shell-signin');
|
|
655
741
|
if (signin) signin.hidden = access.authed !== false;
|
|
656
742
|
var user = document.getElementById('shell-user');
|
|
@@ -862,6 +948,10 @@
|
|
|
862
948
|
social: me.social || null,
|
|
863
949
|
// task 1003744: the resolved permission map, for the `perm:` nav gates.
|
|
864
950
|
permissions: me.permissions || null,
|
|
951
|
+
// task 1003737: the needs roster, for the nav counts. Same payload the
|
|
952
|
+
// hall's Standing card and the CLI nudge read — no new route, no extra
|
|
953
|
+
// poll, and the badge cannot disagree with the roster it came from.
|
|
954
|
+
needs: me.needs || null,
|
|
865
955
|
builder: me.builder,
|
|
866
956
|
});
|
|
867
957
|
}).catch(function () { /* cosmetic only */ });
|
|
@@ -667,6 +667,40 @@ main:focus { outline: none; }
|
|
|
667
667
|
.nav-item--craft .nav-icon,
|
|
668
668
|
.nav-item--craft .nav-item__label { color: var(--accent-ink); }
|
|
669
669
|
|
|
670
|
+
/* task 1003737: the nav count badge — the hall's only badge mechanism, built
|
|
671
|
+
once and generically (shell.js `count:` names a need id; see the NAV model).
|
|
672
|
+
ABSOLUTE, not inline, because the rail has two shapes: it is a 64px icon strip
|
|
673
|
+
until hover/focus opens it, and the badge has to read in BOTH — the whole ask
|
|
674
|
+
was to see that a sitting waits WITHOUT opening anything. Anchoring it to the
|
|
675
|
+
icon's top-right corner is the one position that survives the label leaving
|
|
676
|
+
and re-entering the layout, so nothing reflows when the rail opens.
|
|
677
|
+
Accent because the world's grammar reserves accent for the one live thing
|
|
678
|
+
(the same rule --active and --craft follow); shell.js keeps it off entirely
|
|
679
|
+
when the count is zero, so an accent here always means something waits. */
|
|
680
|
+
.nav-item__badge {
|
|
681
|
+
position: absolute;
|
|
682
|
+
top: 3px;
|
|
683
|
+
right: 3px;
|
|
684
|
+
display: flex;
|
|
685
|
+
align-items: center;
|
|
686
|
+
justify-content: center;
|
|
687
|
+
min-width: 17px;
|
|
688
|
+
height: 17px;
|
|
689
|
+
padding: 0 5px;
|
|
690
|
+
border-radius: var(--radius-pill);
|
|
691
|
+
background: var(--accent);
|
|
692
|
+
color: var(--accent-on);
|
|
693
|
+
font-size: 11px;
|
|
694
|
+
font-weight: 600;
|
|
695
|
+
font-variant-numeric: tabular-nums;
|
|
696
|
+
line-height: 1;
|
|
697
|
+
pointer-events: none;
|
|
698
|
+
}
|
|
699
|
+
/* Open rail: the item grows a 12px gutter and left-aligns, so the badge follows
|
|
700
|
+
the right edge rather than sitting over the label's tail. */
|
|
701
|
+
.app-sidebar:hover .nav-item__badge,
|
|
702
|
+
.app-sidebar:focus-within .nav-item__badge { top: 50%; right: 12px; transform: translateY(-50%); }
|
|
703
|
+
|
|
670
704
|
.app-sidebar__foot {
|
|
671
705
|
padding: 10px 0;
|
|
672
706
|
border-top: 1px solid var(--glass-edge);
|
|
@@ -1015,6 +1049,10 @@ main:focus { outline: none; }
|
|
|
1015
1049
|
.nav-group--collapsible .nav-group__head { display: flex; }
|
|
1016
1050
|
.app-sidebar__brand { justify-content: flex-start; padding: 0 22px; }
|
|
1017
1051
|
.nav-item, .shell-home, .shell-signout { justify-content: flex-start; padding: 0 12px; }
|
|
1052
|
+
/* task 1003737: the drawer IS the rail already open, and there is no hover on
|
|
1053
|
+
a phone to trigger the open-rail rule — so the badge takes the open position
|
|
1054
|
+
here unconditionally, or it would sit over the label on every mobile view. */
|
|
1055
|
+
.nav-item__badge { top: 50%; right: 12px; transform: translateY(-50%); }
|
|
1018
1056
|
.app-sidebar__foot { align-items: stretch; padding: 12px 16px; }
|
|
1019
1057
|
.shell-user { justify-content: flex-start; }
|
|
1020
1058
|
.topbar-menu { display: flex; }
|
package/modules/lifecycle/rot.js
CHANGED
|
@@ -80,6 +80,54 @@ const GOAL_VERBS = Object.freeze([
|
|
|
80
80
|
Object.freeze({ verb: 'water', label: 'Water', method: 'POST', path: '/goals/:id/water', hint: 'leave it — reset the clock' }),
|
|
81
81
|
]);
|
|
82
82
|
|
|
83
|
+
// WHICH of those verbs a given row may actually be offered — the half the list
|
|
84
|
+
// above cannot express. A verb list attached by TIER alone is a list of what the
|
|
85
|
+
// tier CAN do, not of what THIS row can do, and the two differ: promote and demote
|
|
86
|
+
// are each valid for a disjoint set of statuses, so on every row in this feed one
|
|
87
|
+
// of the two is a button whose route answers 409. That is exactly the drift the
|
|
88
|
+
// note above says the design prevents ("the client renders what the server will
|
|
89
|
+
// actually accept"); the intent was right and the implementation ignored status
|
|
90
|
+
// (task 1003751).
|
|
91
|
+
//
|
|
92
|
+
// Mirrored from the route guards, which are the authority:
|
|
93
|
+
// prioritise -> POST /tasks/:id/promote refuses anything but backlog|blocked|
|
|
94
|
+
// abandoned ('cannot_promote', modules/lifecycle/routes/tasks.js)
|
|
95
|
+
// prune -> POST /tasks/:id/demote refuses anything but ready
|
|
96
|
+
// ('cannot_demote', same file)
|
|
97
|
+
// A verb ABSENT from this map is unconditional across the rottable set, and each
|
|
98
|
+
// absence is a fact about its route rather than an omission:
|
|
99
|
+
// kill -> /tasks/:id/abandon refuses only a shipped task or one holding an
|
|
100
|
+
// active claim. Neither can appear here: 'shipped' is not rottable, and
|
|
101
|
+
// a claimed task is 'active', which is not rottable either.
|
|
102
|
+
// water -> filters on TASK_ROTTABLE_STATUSES itself, which IS this feed's set.
|
|
103
|
+
// Because the copy is unavoidable, tests/rot_inbox.mjs reads the accepted statuses
|
|
104
|
+
// back out of the route source and fails if the two ever disagree — the same
|
|
105
|
+
// pairing guard the rot clock gets against migration core_231.
|
|
106
|
+
//
|
|
107
|
+
// A SECOND copy of this decision lives in scripts/gds/backlog-review.js
|
|
108
|
+
// (`verbsFor`, task 1003746), deliberately: that script is an HTTP client that must
|
|
109
|
+
// run from a scoped checkout without loading the server's module tree, so it cannot
|
|
110
|
+
// require this file. The route-guard pairing test below is what keeps both honest.
|
|
111
|
+
const TASK_VERB_ACCEPTS = Object.freeze({
|
|
112
|
+
prioritise: Object.freeze(['backlog', 'blocked', 'abandoned']),
|
|
113
|
+
prune: Object.freeze(['ready']),
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
// verbsFor(tier, status) — the verbs to attach to ONE row: the tier's catalogue
|
|
117
|
+
// narrowed to what this row's status will actually be accepted for.
|
|
118
|
+
//
|
|
119
|
+
// A goal gets its two unconditionally: /goals/:id/archive refuses only an ALREADY
|
|
120
|
+
// archived goal, and /goals/:id/water filters on the rottable set — so for a goal
|
|
121
|
+
// in this feed (status 'open') both always apply.
|
|
122
|
+
function verbsFor(tier, status) {
|
|
123
|
+
if (tier === 'goal') return GOAL_VERBS;
|
|
124
|
+
const s = String(status);
|
|
125
|
+
return Object.freeze(TASK_VERBS.filter((v) => {
|
|
126
|
+
const accepts = TASK_VERB_ACCEPTS[v.verb];
|
|
127
|
+
return !accepts || accepts.includes(s);
|
|
128
|
+
}));
|
|
129
|
+
}
|
|
130
|
+
|
|
83
131
|
const MAX_ROT_ROWS = 200;
|
|
84
132
|
|
|
85
133
|
// rottingFor({ builderId, canSeeAll, rotDays, limit }) — the rot lines this caller
|
|
@@ -146,7 +194,7 @@ async function rottingFor({ builderId, canSeeAll = false, rotDays, limit = 50 }
|
|
|
146
194
|
|
|
147
195
|
const cutoffAt = new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString();
|
|
148
196
|
return {
|
|
149
|
-
rows: rows.map((r) => ({ ...r, verbs: r.tier
|
|
197
|
+
rows: rows.map((r) => ({ ...r, verbs: verbsFor(r.tier, r.status) })),
|
|
150
198
|
rotDays: days,
|
|
151
199
|
cutoff: cutoffAt,
|
|
152
200
|
};
|
|
@@ -203,6 +251,8 @@ module.exports = {
|
|
|
203
251
|
GOAL_ROT_CLOCK,
|
|
204
252
|
TASK_VERBS,
|
|
205
253
|
GOAL_VERBS,
|
|
254
|
+
TASK_VERB_ACCEPTS,
|
|
255
|
+
verbsFor,
|
|
206
256
|
MAX_ROT_ROWS,
|
|
207
257
|
rottingFor,
|
|
208
258
|
water,
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.643",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.19.
|
|
9
|
+
"version": "1.19.643",
|
|
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.
|
|
3
|
+
"version": "1.19.643",
|
|
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",
|
|
@@ -320,7 +320,7 @@ function transformSettings(srcPath) {
|
|
|
320
320
|
const HOOK_TEXT_EXT = new Set(['.js', '.mjs', '.cjs', '.json', '.sh', '.md', '.txt']); // hooks are .js — rebrand them too
|
|
321
321
|
|
|
322
322
|
function materializeClaude({ coreRoot, instanceDir, dryRun = false, overwrite = true, log = () => {} }) {
|
|
323
|
-
const summary = { skipped: false, skills: 0, moduleSkills: 0, settings: false, hooks: 0, scheduledTasks: 0, skippedFiles: [], excludedSkills: [], rebrandRules: 0, claudeMd: false, notes: [] };
|
|
323
|
+
const summary = { skipped: false, skills: 0, moduleSkills: 0, settings: false, hooks: 0, scheduledTasks: 0, skippedFiles: [], excludedSkills: [], withdrawnSkills: [], rebrandRules: 0, claudeMd: false, notes: [] };
|
|
324
324
|
if (path.resolve(instanceDir) === path.resolve(coreRoot)) {
|
|
325
325
|
summary.skipped = true;
|
|
326
326
|
summary.notes.push('instance dir is the core root — nothing to materialize (byte-identical no-op).');
|
|
@@ -351,7 +351,11 @@ function materializeClaude({ coreRoot, instanceDir, dryRun = false, overwrite =
|
|
|
351
351
|
// carries them as untracked copies plus two dotfiles; step 1 must not ship those as core
|
|
352
352
|
// skills — step 1b lands them from the module, transformed, like everywhere else.
|
|
353
353
|
const materialisedInCore = readModuleSkillsManifest(path.join(coreRoot, '.claude', 'skills')).skills;
|
|
354
|
-
|
|
354
|
+
// The INSTANCE's own provenance record — what a PREVIOUS materialize landed here.
|
|
355
|
+
// Distinct from materialisedInCore above (that one is the core checkout's). Step 1c
|
|
356
|
+
// withdraws from this set and nothing else.
|
|
357
|
+
const landedPreviously = readInstanceSkillsManifest(path.join(destClaude, 'skills')).skills;
|
|
358
|
+
const skillSkip = (rel) => { const top = String(rel).split(/[\\/]/)[0]; return skipSkills.has(top) || top === MODULE_SKILLS_MANIFEST || top === INSTANCE_SKILLS_MANIFEST || top === '.gitignore' || Object.prototype.hasOwnProperty.call(materialisedInCore, top); };
|
|
355
359
|
|
|
356
360
|
// 1. skills — ship in the core package; copy, rewrite invocations→bongos, rebrand
|
|
357
361
|
// identity, and exclude any skill owned by a disabled module.
|
|
@@ -390,6 +394,67 @@ function materializeClaude({ coreRoot, instanceDir, dryRun = false, overwrite =
|
|
|
390
394
|
}
|
|
391
395
|
if (summary.moduleSkills) log(` ${dryRun ? '[dry-run] would materialize' : '✓ materialized'} ${summary.moduleSkills} module-owned skill file(s) → .claude/skills/ (from modules/<key>/skills/)`);
|
|
392
396
|
|
|
397
|
+
// 1c. WITHDRAW a skill a previous materialize landed here that this run no longer
|
|
398
|
+
// lands — the module was switched off (or stopped declaring the name) on an
|
|
399
|
+
// instance that ALREADY has the files. Step 1's skillSkip and step 1b's
|
|
400
|
+
// `enabled` check both only decline to COPY, which is the whole bug: disabling
|
|
401
|
+
// a module was honoured on a fresh instance and silently ignored on
|
|
402
|
+
// `bongos upgrade`, leaving stale dirs in .claude/skills/ for the harness to
|
|
403
|
+
// keep loading (task 1003632, found by the on/off proof of task 1003331).
|
|
404
|
+
// materializeModuleSkillsIntoCore has done this for the core checkout since
|
|
405
|
+
// task 1003330; this is the same mechanism for the instance side.
|
|
406
|
+
//
|
|
407
|
+
// PROVENANCE-GATED, and that gate is the safety property: only a name the
|
|
408
|
+
// manifest says a previous run LANDED is eligible. A skill the instance's owner
|
|
409
|
+
// wrote by hand was never in the manifest, so it can never be deleted here even
|
|
410
|
+
// if a module happens to declare the same name. With no manifest at all — an
|
|
411
|
+
// instance last materialized before this shipped — nothing is withdrawn and a
|
|
412
|
+
// note says so: that first run writes the provenance, and every run after it
|
|
413
|
+
// honours a disable. Refusing to guess is the point; deleting a builder's skill
|
|
414
|
+
// because we cannot prove we put it there is the one outcome worth avoiding.
|
|
415
|
+
const skillsDest = path.join(destClaude, 'skills');
|
|
416
|
+
const landedNow = {};
|
|
417
|
+
for (const src of sources) {
|
|
418
|
+
if (!src.enabled) continue;
|
|
419
|
+
// A dry run copied nothing, so it records what WOULD land; a real run confirms
|
|
420
|
+
// the dir is actually there before claiming provenance over it.
|
|
421
|
+
if (!dryRun && !fs.existsSync(path.join(skillsDest, src.name))) continue;
|
|
422
|
+
landedNow[src.name] = { module: src.module, source: path.relative(coreRoot, src.dir).split(path.sep).join('/'), vendored: src.vendored };
|
|
423
|
+
}
|
|
424
|
+
// A core-SHIPPED skill whose name a module also declares is landed by step 1, not
|
|
425
|
+
// step 1b — but it is withdrawable by the same disabled-module rule, so it needs
|
|
426
|
+
// provenance too.
|
|
427
|
+
for (const mod of roster) {
|
|
428
|
+
if (!mod.enabled) continue;
|
|
429
|
+
for (const name of mod.skills) {
|
|
430
|
+
if (landedNow[name] || !fs.existsSync(path.join(skillsSrc, name))) continue;
|
|
431
|
+
landedNow[name] = { module: mod.key, source: `.claude/skills/${name}`, coreShipped: true };
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
const withdrawable = Object.keys(landedPreviously).filter((n) => SKILL_NAME_RE.test(n) && !landedNow[n]);
|
|
435
|
+
if (!overwrite) {
|
|
436
|
+
// adopt mode never clobbers what the owner already has; withdrawing is a clobber.
|
|
437
|
+
if (withdrawable.length) summary.notes.push(`${withdrawable.length} skill(s) of a now-disabled module were left in place (adopt mode does not withdraw): ${withdrawable.sort().join(', ')}.`);
|
|
438
|
+
} else {
|
|
439
|
+
for (const name of withdrawable.sort()) {
|
|
440
|
+
const dir = path.join(skillsDest, name);
|
|
441
|
+
if (!fs.existsSync(dir)) continue;
|
|
442
|
+
if (!dryRun) fs.rmSync(dir, { recursive: true, force: true });
|
|
443
|
+
summary.withdrawnSkills.push(name);
|
|
444
|
+
}
|
|
445
|
+
if (summary.withdrawnSkills.length) log(` ${dryRun ? '[dry-run] would withdraw' : '✓ withdrew'} ${summary.withdrawnSkills.length} stale skill dir(s) whose module is no longer enabled: ${summary.withdrawnSkills.join(', ')}`);
|
|
446
|
+
}
|
|
447
|
+
if (!Object.keys(landedPreviously).length && summary.excludedSkills.length) {
|
|
448
|
+
summary.notes.push('no .instance-skills.json provenance in .claude/skills/ yet, so nothing was withdrawn — this run records it; a later run can withdraw the skills of a module that has since been disabled.');
|
|
449
|
+
}
|
|
450
|
+
// Record what landed, so the NEXT run can withdraw what this one placed.
|
|
451
|
+
if (!dryRun && overwrite) {
|
|
452
|
+
const names = Object.keys(landedNow).sort();
|
|
453
|
+
const manifest = { _doc: 'GENERATED by scripts/gds/claude-materialize.js (task 1003632): the module-declared skills a materialize landed in this instance. It is the provenance record that lets a later run WITHDRAW a skill whose module has since been disabled — a name absent from here is never deleted, so a hand-written skill is safe. Commit it; do not hand-edit.', skills: Object.fromEntries(names.map((n) => [n, landedNow[n]])) };
|
|
454
|
+
fs.mkdirSync(skillsDest, { recursive: true });
|
|
455
|
+
fs.writeFileSync(path.join(skillsDest, INSTANCE_SKILLS_MANIFEST), JSON.stringify(manifest, null, 2) + '\n');
|
|
456
|
+
}
|
|
457
|
+
|
|
393
458
|
// 2. settings.json — topology; present in the checkout/core repo, absent in a package-only install.
|
|
394
459
|
const settingsSrc = path.join(srcClaude, 'settings.json');
|
|
395
460
|
if (fs.existsSync(settingsSrc)) {
|
|
@@ -494,10 +559,23 @@ function isCoreCheckoutRoot(root) {
|
|
|
494
559
|
}
|
|
495
560
|
|
|
496
561
|
const MODULE_SKILLS_MANIFEST = '.module-skills.json';
|
|
497
|
-
|
|
498
|
-
|
|
562
|
+
// The INSTANCE-side provenance record, deliberately a DIFFERENT filename from the
|
|
563
|
+
// core's (task 1003632). They are different artifacts: MODULE_SKILLS_MANIFEST lists
|
|
564
|
+
// the untracked copies materializeModuleSkillsIntoCore made inside a core CHECKOUT,
|
|
565
|
+
// and is asserted never to travel to an instance; this one lists what materializeClaude
|
|
566
|
+
// landed INTO an instance, and is what step 1c withdraws against. Sharing one name made
|
|
567
|
+
// the instance's own record look like a core dotfile that had leaked across the boundary.
|
|
568
|
+
const INSTANCE_SKILLS_MANIFEST = '.instance-skills.json';
|
|
569
|
+
function readSkillsManifest(skillsDir, file) {
|
|
570
|
+
const m = readJsonSafe(path.join(skillsDir, file));
|
|
499
571
|
return m && m.skills && typeof m.skills === 'object' ? m : { skills: {} };
|
|
500
572
|
}
|
|
573
|
+
function readModuleSkillsManifest(skillsDir) {
|
|
574
|
+
return readSkillsManifest(skillsDir, MODULE_SKILLS_MANIFEST);
|
|
575
|
+
}
|
|
576
|
+
function readInstanceSkillsManifest(skillsDir) {
|
|
577
|
+
return readSkillsManifest(skillsDir, INSTANCE_SKILLS_MANIFEST);
|
|
578
|
+
}
|
|
501
579
|
function materializeModuleSkillsIntoCore({ coreRoot, dryRun = false, clean = false, log = () => {} }) {
|
|
502
580
|
const skillsDir = path.join(coreRoot, '.claude', 'skills');
|
|
503
581
|
const summary = { materialized: [], removed: [], refused: [], excluded: [], keptCore: [], files: 0, notes: [] };
|
|
@@ -559,4 +637,4 @@ if (require.main === module) {
|
|
|
559
637
|
process.exit(s.refused.length ? 1 : 0);
|
|
560
638
|
}
|
|
561
639
|
|
|
562
|
-
module.exports = { scriptToVerb, bongosify, transformSettings, copyTreeTransformed, materializeClaude, materializeModuleSkillsIntoCore, isCoreCheckoutRoot, readModuleSkillsManifest, MODULE_SKILLS_MANIFEST, GDS_INVOCATION_RE, buildRebrandRules, rebrandText, disabledModuleSkills, moduleSkillRoster, moduleSkillSources, vendoredSkillGate, SKILL_NAME_RE, VENDOR_DEFAULT_POLICY };
|
|
640
|
+
module.exports = { scriptToVerb, bongosify, transformSettings, copyTreeTransformed, materializeClaude, materializeModuleSkillsIntoCore, isCoreCheckoutRoot, readModuleSkillsManifest, MODULE_SKILLS_MANIFEST, readInstanceSkillsManifest, INSTANCE_SKILLS_MANIFEST, GDS_INVOCATION_RE, buildRebrandRules, rebrandText, disabledModuleSkills, moduleSkillRoster, moduleSkillSources, vendoredSkillGate, SKILL_NAME_RE, VENDOR_DEFAULT_POLICY };
|
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.
|
|
74
|
+
const CORE_VERSION = '1.19.643'; // 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');
|
|
@@ -255,6 +255,98 @@ t('disabledModuleSkills: only skills of DISABLED modules are collected (default
|
|
|
255
255
|
rmSync(core, { recursive: true, force: true }); rmSync(inst, { recursive: true, force: true });
|
|
256
256
|
});
|
|
257
257
|
|
|
258
|
+
// ---- withdrawing a disabled module's skills on an EXISTING instance (task 1003632) ----
|
|
259
|
+
// The bug: materializeClaude honoured `{"<key>": false}` on a FRESH instance (nothing
|
|
260
|
+
// was copied) but only ever declined to COPY — so on an instance that already had the
|
|
261
|
+
// files, `bongos upgrade` left them in .claude/skills/ for the harness to keep loading.
|
|
262
|
+
// The withdrawal is PROVENANCE-GATED, which is the property that matters most here: a
|
|
263
|
+
// skill the instance's owner wrote by hand is never in the manifest, so it must survive
|
|
264
|
+
// even when a module declares the very same name.
|
|
265
|
+
|
|
266
|
+
// Build a core with one module shipping two skills, and an instance with the module ON.
|
|
267
|
+
function withdrawFixture() {
|
|
268
|
+
const core = mkdtempSync(join(tmpdir(), 'wd-core-'));
|
|
269
|
+
const inst = mkdtempSync(join(tmpdir(), 'wd-inst-'));
|
|
270
|
+
const modDir = join(core, 'modules', 'foo');
|
|
271
|
+
mkdirSync(join(modDir, 'skills', 'alpha'), { recursive: true });
|
|
272
|
+
mkdirSync(join(modDir, 'skills', 'beta'), { recursive: true });
|
|
273
|
+
mkdirSync(join(core, '.claude', 'skills'), { recursive: true });
|
|
274
|
+
writeFileSync(join(modDir, 'module.json'), JSON.stringify({ key: 'foo', default: true, contributes: { skills: ['alpha', 'beta'] } }));
|
|
275
|
+
writeFileSync(join(modDir, 'skills', 'alpha', 'SKILL.md'), '---\nname: alpha\n---\nbody\n');
|
|
276
|
+
writeFileSync(join(modDir, 'skills', 'beta', 'SKILL.md'), '---\nname: beta\n---\nbody\n');
|
|
277
|
+
return { core, inst, modDir };
|
|
278
|
+
}
|
|
279
|
+
const disable = (inst, key) => {
|
|
280
|
+
mkdirSync(join(inst, 'config'), { recursive: true });
|
|
281
|
+
writeFileSync(join(inst, 'config', 'modules.json'), JSON.stringify({ modules: { [key]: false } }));
|
|
282
|
+
};
|
|
283
|
+
|
|
284
|
+
t('materializeClaude: a module disabled on an EXISTING instance has its skills WITHDRAWN, not left to linger', () => {
|
|
285
|
+
const { core, inst } = withdrawFixture();
|
|
286
|
+
// upgrade 1 — module on: both land, and provenance is recorded
|
|
287
|
+
const first = m.materializeClaude({ coreRoot: core, instanceDir: inst, dryRun: false });
|
|
288
|
+
assert.equal(first.moduleSkills, 2);
|
|
289
|
+
assert.ok(existsSync(join(inst, '.claude', 'skills', 'alpha', 'SKILL.md')));
|
|
290
|
+
assert.ok(existsSync(join(inst, '.claude', 'skills', 'beta', 'SKILL.md')));
|
|
291
|
+
assert.deepEqual(first.withdrawnSkills, [], 'nothing to withdraw on the way in');
|
|
292
|
+
const mani = m.readInstanceSkillsManifest(join(inst, '.claude', 'skills'));
|
|
293
|
+
assert.deepEqual(Object.keys(mani.skills).sort(), ['alpha', 'beta'], 'the run records what it landed');
|
|
294
|
+
|
|
295
|
+
// upgrade 2 — module off: THIS is the bug. Both dirs must go.
|
|
296
|
+
disable(inst, 'foo');
|
|
297
|
+
const second = m.materializeClaude({ coreRoot: core, instanceDir: inst, dryRun: false });
|
|
298
|
+
assert.deepEqual(second.withdrawnSkills, ['alpha', 'beta']);
|
|
299
|
+
assert.ok(!existsSync(join(inst, '.claude', 'skills', 'alpha')), 'alpha withdrawn');
|
|
300
|
+
assert.ok(!existsSync(join(inst, '.claude', 'skills', 'beta')), 'beta withdrawn');
|
|
301
|
+
assert.deepEqual(Object.keys(m.readInstanceSkillsManifest(join(inst, '.claude', 'skills')).skills), [], 'provenance now records nothing landed');
|
|
302
|
+
|
|
303
|
+
// upgrade 3 — idempotent: nothing left to withdraw, no throw on the absent dirs
|
|
304
|
+
const third = m.materializeClaude({ coreRoot: core, instanceDir: inst, dryRun: false });
|
|
305
|
+
assert.deepEqual(third.withdrawnSkills, []);
|
|
306
|
+
for (const d of [core, inst]) rmSync(d, { recursive: true, force: true });
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
t('materializeClaude: a skill the module STOPPED declaring is withdrawn too', () => {
|
|
310
|
+
const { core, inst, modDir } = withdrawFixture();
|
|
311
|
+
m.materializeClaude({ coreRoot: core, instanceDir: inst, dryRun: false });
|
|
312
|
+
// the module keeps only alpha
|
|
313
|
+
writeFileSync(join(modDir, 'module.json'), JSON.stringify({ key: 'foo', default: true, contributes: { skills: ['alpha'] } }));
|
|
314
|
+
const res = m.materializeClaude({ coreRoot: core, instanceDir: inst, dryRun: false });
|
|
315
|
+
assert.deepEqual(res.withdrawnSkills, ['beta']);
|
|
316
|
+
assert.ok(existsSync(join(inst, '.claude', 'skills', 'alpha')), 'a still-declared skill stays');
|
|
317
|
+
assert.ok(!existsSync(join(inst, '.claude', 'skills', 'beta')));
|
|
318
|
+
for (const d of [core, inst]) rmSync(d, { recursive: true, force: true });
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
t('materializeClaude: a HAND-WRITTEN skill is never withdrawn, even when a disabled module declares its name', () => {
|
|
322
|
+
const { core, inst } = withdrawFixture();
|
|
323
|
+
// The owner wrote .claude/skills/alpha themselves; no materialize ever landed it,
|
|
324
|
+
// so no manifest names it. Disabling foo must not touch it.
|
|
325
|
+
mkdirSync(join(inst, '.claude', 'skills', 'alpha'), { recursive: true });
|
|
326
|
+
writeFileSync(join(inst, '.claude', 'skills', 'alpha', 'SKILL.md'), 'MINE — hand written\n');
|
|
327
|
+
disable(inst, 'foo');
|
|
328
|
+
const res = m.materializeClaude({ coreRoot: core, instanceDir: inst, dryRun: false });
|
|
329
|
+
assert.deepEqual(res.withdrawnSkills, [], 'no provenance for it, so it is not ours to delete');
|
|
330
|
+
assert.equal(readFileSync(join(inst, '.claude', 'skills', 'alpha', 'SKILL.md'), 'utf8'), 'MINE — hand written\n', 'the owner\'s file is untouched');
|
|
331
|
+
for (const d of [core, inst]) rmSync(d, { recursive: true, force: true });
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
t('materializeClaude: --dry-run reports the withdrawal without performing it; adopt mode declines to withdraw', () => {
|
|
335
|
+
const { core, inst } = withdrawFixture();
|
|
336
|
+
m.materializeClaude({ coreRoot: core, instanceDir: inst, dryRun: false });
|
|
337
|
+
disable(inst, 'foo');
|
|
338
|
+
|
|
339
|
+
const dry = m.materializeClaude({ coreRoot: core, instanceDir: inst, dryRun: true });
|
|
340
|
+
assert.deepEqual(dry.withdrawnSkills, ['alpha', 'beta'], 'reports what it would withdraw');
|
|
341
|
+
assert.ok(existsSync(join(inst, '.claude', 'skills', 'alpha')), 'a dry run deletes nothing');
|
|
342
|
+
|
|
343
|
+
const adopt = m.materializeClaude({ coreRoot: core, instanceDir: inst, dryRun: false, overwrite: false });
|
|
344
|
+
assert.deepEqual(adopt.withdrawnSkills, [], 'adopt never clobbers what the owner has');
|
|
345
|
+
assert.ok(existsSync(join(inst, '.claude', 'skills', 'alpha')));
|
|
346
|
+
assert.ok(adopt.notes.some((n) => /adopt mode does not withdraw/.test(n)), 'and says so');
|
|
347
|
+
for (const d of [core, inst]) rmSync(d, { recursive: true, force: true });
|
|
348
|
+
});
|
|
349
|
+
|
|
258
350
|
// ---- module-owned skills (task 1003321 / ADR 0198) ---------------------------
|
|
259
351
|
// A module ships a skill from modules/<key>/skills/<name>/ (its own) or
|
|
260
352
|
// modules/<key>/skills/vendor/<name>/ (third-party + PROVENANCE.md); only a DECLARED
|
|
@@ -473,6 +565,9 @@ t('--module-skills-only: an instance materialised FROM a core that carries mater
|
|
|
473
565
|
assert.ok(res.moduleSkills >= 2, 'alpha landed through step 1b');
|
|
474
566
|
assert.match(readFileSync(join(inst, '.claude', 'skills', 'alpha', 'SKILL.md'), 'utf8'), /bongos ship 1/, 'transformed on the way into the instance');
|
|
475
567
|
for (const f of [m.MODULE_SKILLS_MANIFEST, '.gitignore']) assert.ok(!existsSync(join(inst, '.claude', 'skills', f)), `${f} is not shipped to an instance`);
|
|
568
|
+
// The instance writes its OWN provenance record under a different name (task 1003632)
|
|
569
|
+
// — it is not the core's dotfile leaking across the boundary the line above guards.
|
|
570
|
+
assert.ok(existsSync(join(inst, '.claude', 'skills', m.INSTANCE_SKILLS_MANIFEST)), 'the instance records what it landed, under its own filename');
|
|
476
571
|
assert.ok(existsSync(join(inst, '.claude', 'skills', 'core-one', 'SKILL.md')) && existsSync(join(inst, '.claude', 'skills', 'shared', 'SKILL.md')), 'the core skills still land');
|
|
477
572
|
rmSync(core, { recursive: true, force: true }); rmSync(inst, { recursive: true, force: true });
|
|
478
573
|
});
|
|
@@ -200,6 +200,9 @@ test('pending votes are action_needed and deep-link the oldest sitting', () => {
|
|
|
200
200
|
assert.equal(n.id, 'board_votes');
|
|
201
201
|
assert.equal(n.state, 'action_needed');
|
|
202
202
|
assert.equal(n.action.href, '/board-room?item=1');
|
|
203
|
+
// task 1003737: the number, machine-readable. The message has always carried it
|
|
204
|
+
// in prose, which a sentence can render and the nav badge cannot.
|
|
205
|
+
assert.equal(n.count, 9, 'the need states its count for a badge to read');
|
|
203
206
|
// The item is a query param, because `/board-room` reads `?item=` and a
|
|
204
207
|
// FRAGMENT would never reach the server (ADR 0266). The old
|
|
205
208
|
// `/government…#board-room` form survives only as government.js's
|
|
@@ -207,6 +210,22 @@ test('pending votes are action_needed and deep-link the oldest sitting', () => {
|
|
|
207
210
|
assert.ok(!n.action.href.includes('#board-room'), 'never emits the retired hash form');
|
|
208
211
|
});
|
|
209
212
|
|
|
213
|
+
test('the founding grace keeps the count but drops the demand', () => {
|
|
214
|
+
// The badge keys on state, not on the number, precisely because of this: the
|
|
215
|
+
// grace rewrites the row to 'covered' and the count rides through the spread.
|
|
216
|
+
// A renderer keyed on `count` alone would shout through a new instance's
|
|
217
|
+
// first days, which is the one thing the grace exists to prevent.
|
|
218
|
+
const out = needs.computeNeeds({
|
|
219
|
+
rank: 'archon', ownKeySet: true,
|
|
220
|
+
pendingBoardVotes: { count: 5, first_item_id: '1' },
|
|
221
|
+
isFoundingOwner: true, founderCreatedAt: new Date().toISOString(),
|
|
222
|
+
});
|
|
223
|
+
const row = out.items.find((i) => i.id === 'board_votes');
|
|
224
|
+
assert.equal(row.count, 5, 'the count survives');
|
|
225
|
+
assert.equal(row.state, 'covered', 'but it is no longer a demand');
|
|
226
|
+
assert.equal(out.has_action_needed, false);
|
|
227
|
+
});
|
|
228
|
+
|
|
210
229
|
test('a count with no item id still links the room', () => {
|
|
211
230
|
const n = needs.boardVotesNeed({ pendingBoardVotes: { count: 2 } });
|
|
212
231
|
assert.equal(n.action.href, '/board-room');
|