@bongos/core 1.19.794 → 1.19.796

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.
@@ -98,7 +98,7 @@
98
98
  <script src="/builders/shell.js?v=2026-07-02-shell1"></script>
99
99
  <script src="/builders/palette.js" defer></script>
100
100
  <script src="/builders/dom-utils.js?v=2026-06-23-contrib"></script>
101
- <script src="/builders/collab-lib.js?v=2026-09-16-blocks"></script>
102
- <script src="/builders/collab.js?v=2026-09-16-blocks"></script>
101
+ <script src="/builders/collab-lib.js?v=2026-09-16-twosentence"></script>
102
+ <script src="/builders/collab.js?v=2026-09-16-twosentence"></script>
103
103
  </body>
104
104
  </html>
@@ -13,11 +13,15 @@
13
13
  //
14
14
  // A row is the family's .ov-row--lead: the person's FACE first (collab-lib
15
15
  // faceOf: the asker on what was asked of you and on the board, the addressee on
16
- // your own asks), the ask's first sentence as the title and the rest as the sub
17
- // (titleOf), the direction as a fact with a door to their profile, the task or
18
- // goal as a chip, and the AGE as a state pill in the right column, so a glance
19
- // down a list reads the ages in words and in the verification colours. Every
20
- // rule that can be pure lives in collab-lib.js where a test can execute it.
16
+ // your own asks), then the ask's first TWO sentences and no more (summaryOf),
17
+ // then the direction as a fact with a door to their profile, the task or goal as
18
+ // a chip, and the AGE as a state pill in the right column, so a glance down a
19
+ // list reads the ages in words and in the verification colours. Every rule that
20
+ // can be pure lives in collab-lib.js where a test can execute it.
21
+ //
22
+ // Under those two sentences, when there is more ask than fits: a door onto the
23
+ // whole thing and a control that copies it verbatim for a fresh session (task
24
+ // 1003960).
21
25
  //
22
26
  // MATCHING IS THE SERVER'S JOB, not this file's. GET /help-requests/for-me derives
23
27
  // the viewer's crafts from their own profile; sending a craft from here would be
@@ -55,10 +59,40 @@
55
59
  return `<span class="ov-row__when">${escapeHtml(d.toLocaleDateString(undefined, { day: 'numeric', month: 'short', year: 'numeric' }))}</span>`;
56
60
  }
57
61
 
58
- function titleHtml(req) {
59
- const t = L.titleOf(req.what_is_stuck);
60
- return `<span class="ov-row__title">${escapeHtml(t.title)}</span>`
61
- + (t.rest ? `<p class="ov-row__sub">${escapeHtml(t.rest)}</p>` : '');
62
+ // A DOM id per row that is stable, unique and safe to put in an attribute. The
63
+ // same request appears in up to three blocks at once, so the block's key is
64
+ // part of the id — without it "all open asks" and "your asks" would hand the
65
+ // same id to two elements and every aria-controls on the page would resolve to
66
+ // whichever painted first.
67
+ function askId(key, id) {
68
+ return `cb-ask-${key}-${String(id).replace(/[^A-Za-z0-9_-]/g, '')}`;
69
+ }
70
+
71
+ // THE FACE IS TWO SENTENCES, AND THE ASK ITSELF IS BEHIND A DOOR (owner
72
+ // direction, task 1003960: "the text here does not need to be visible, all we
73
+ // need is two sentences"). An ask is written to be answerable by someone with
74
+ // none of your context — the skill asks for what is stuck, why this person, the
75
+ // steps, how to verify and what was already tried, up to 4000 characters — so
76
+ // the words are worth keeping and were only ever at the wrong altitude.
77
+ //
78
+ // THE COPY CONTROL IS THE POINT OF THE DOOR, not an extra. What is behind it is
79
+ // a handoff: the reader's next move is usually to open a fresh session and
80
+ // paste the whole ask in as its opening context, and a page that made them
81
+ // expand, select and drag over four thousand characters would be asking them to
82
+ // do by hand the one thing it knows they came to do. It sits beside the door in
83
+ // the CLOSED state on purpose — taking the ask away does not require reading it
84
+ // here first.
85
+ function askHtml(req, key) {
86
+ const s = L.summaryOf(req.what_is_stuck);
87
+ let html = `<span class="ov-row__title">${escapeHtml(s.title)}</span>`;
88
+ if (s.lead) html += `<p class="ov-row__sub">${escapeHtml(s.lead)}</p>`;
89
+ if (!s.more) return html;
90
+ const fid = askId(key, req.id);
91
+ return html + `<div class="cb-ask">
92
+ <button class="cb-ask__door" type="button" data-ask-toggle aria-expanded="false" aria-controls="${fid}">Read the full ask</button>
93
+ <button class="cb-ask__copy" type="button" data-ask-copy="${fid}">Copy for Session Start</button>
94
+ </div>
95
+ <p class="cb-ask__full" id="${fid}" hidden>${escapeHtml(s.more)}</p>`;
62
96
  }
63
97
 
64
98
  function contextChip(req) {
@@ -142,11 +176,11 @@
142
176
  // chat mirror). What the page owes a helper is the request, its age, its
143
177
  // context, and who to answer — and their face. A by-name ask is the personal
144
178
  // call on this reader, so its row is the LIT one (.cb-row--live).
145
- function forMeRow(req, nowMs, extra) {
179
+ function forMeRow(req, nowMs, extra, key) {
146
180
  const live = req.needs_builder_id != null ? ' cb-row--live' : '';
147
181
  return `<li class="ov-row ov-row--lead cb-row${live}" data-id="${escapeHtml(String(req.id))}"${extra ? ' data-extra hidden' : ''}>
148
182
  ${faceHtml(L.faceOf(req, 'in'))}
149
- <div class="ov-row__main">${titleHtml(req)}
183
+ <div class="ov-row__main">${askHtml(req, key)}
150
184
  <div class="ov-row__facts">${directionFact(req, 'in')}${contextChip(req)}</div>
151
185
  </div>
152
186
  <div class="ov-row__right">${ageChip(req, nowMs)}${whenLine(req.created_at)}</div>
@@ -156,11 +190,11 @@
156
190
  // Your own ask, led by who you asked, with the two settle actions. Only the
157
191
  // asker can settle — the server enforces it in the UPDATE's WHERE, this is
158
192
  // just the affordance.
159
- function mineRow(req, nowMs, extra) {
193
+ function mineRow(req, nowMs, extra, key) {
160
194
  const id = escapeHtml(String(req.id));
161
195
  return `<li class="ov-row ov-row--lead cb-row" data-id="${id}"${extra ? ' data-extra hidden' : ''}>
162
196
  ${faceHtml(L.faceOf(req, 'out'))}
163
- <div class="ov-row__main">${titleHtml(req)}
197
+ <div class="ov-row__main">${askHtml(req, key)}
164
198
  <div class="ov-row__facts">${directionFact(req, 'out')}${contextChip(req)}</div>
165
199
  </div>
166
200
  <div class="ov-row__right">${ageChip(req, nowMs)}
@@ -174,10 +208,10 @@
174
208
 
175
209
  // A row on the board of everyone's asks: the asker's face, the third-person
176
210
  // line, no actions — settling is the asker's, in their own block.
177
- function allRow(req, nowMs, extra) {
211
+ function allRow(req, nowMs, extra, key) {
178
212
  return `<li class="ov-row ov-row--lead cb-row" data-id="${escapeHtml(String(req.id))}"${extra ? ' data-extra hidden' : ''}>
179
213
  ${faceHtml(L.faceOf(req, 'in'))}
180
- <div class="ov-row__main">${titleHtml(req)}
214
+ <div class="ov-row__main">${askHtml(req, key)}
181
215
  <div class="ov-row__facts">${boardFact(req)}${contextChip(req)}</div>
182
216
  </div>
183
217
  <div class="ov-row__right">${ageChip(req, nowMs)}${whenLine(req.created_at)}</div>
@@ -195,8 +229,8 @@
195
229
  const { shown, hidden } = L.splitShown(list, SHOWN);
196
230
  const host = $(`#cb-${key}`);
197
231
  if (host) {
198
- host.innerHTML = shown.map((r) => rowFn(r, nowMs, false)).join('')
199
- + hidden.map((r) => rowFn(r, nowMs, true)).join('');
232
+ host.innerHTML = shown.map((r) => rowFn(r, nowMs, false, key)).join('')
233
+ + hidden.map((r) => rowFn(r, nowMs, true, key)).join('');
200
234
  }
201
235
  const n = $(`#cb-${key}-n`);
202
236
  if (n) n.textContent = list.length ? String(list.length) : '';
@@ -213,6 +247,59 @@
213
247
  }
214
248
  }
215
249
 
250
+ // The per-row door. Independent of the block's "Show all N", which flips
251
+ // [data-extra] on the <li>s: this flips one <p> inside one row, so opening a
252
+ // row inside a condensed block and then expanding the block cannot leave either
253
+ // control describing a state the page is not in.
254
+ function toggleAsk(btn) {
255
+ const full = document.getElementById(btn.getAttribute('aria-controls'));
256
+ if (!full) return;
257
+ const open = btn.getAttribute('aria-expanded') === 'true';
258
+ full.hidden = open;
259
+ btn.setAttribute('aria-expanded', open ? 'false' : 'true');
260
+ btn.textContent = open ? 'Read the full ask' : 'Hide the full ask';
261
+ }
262
+
263
+ // Clipboard, with the hall's own fallback (copy-on-click.js's rule): the async
264
+ // API is absent outside a secure context, and a copy control that silently did
265
+ // nothing on a plain-http preview would be worse than no control at all — so
266
+ // the caller is always told which way it went.
267
+ function writeClipboard(text) {
268
+ if (navigator.clipboard && navigator.clipboard.writeText && window.isSecureContext) {
269
+ return navigator.clipboard.writeText(text).then(() => true, () => legacyCopy(text));
270
+ }
271
+ return Promise.resolve(legacyCopy(text));
272
+ }
273
+
274
+ function legacyCopy(text) {
275
+ try {
276
+ const ta = document.createElement('textarea');
277
+ ta.value = text;
278
+ ta.setAttribute('readonly', '');
279
+ ta.style.position = 'fixed';
280
+ ta.style.top = '-1000px';
281
+ ta.style.opacity = '0';
282
+ document.body.appendChild(ta);
283
+ ta.select();
284
+ const ok = document.execCommand('copy');
285
+ document.body.removeChild(ta);
286
+ return ok;
287
+ } catch (_) { return false; }
288
+ }
289
+
290
+ // textContent, not innerText: the hidden <p> holds the ask verbatim including
291
+ // its line breaks, and innerText on a hidden element is not guaranteed to
292
+ // return them. What lands on the clipboard is exactly what the asker wrote.
293
+ function copyAsk(btn) {
294
+ const full = document.getElementById(btn.getAttribute('data-ask-copy'));
295
+ if (!full) return;
296
+ const text = full.textContent || '';
297
+ if (!text) return;
298
+ writeClipboard(text).then((ok) => {
299
+ toast(ok ? 'Copied — paste it in to start a session on this.' : 'Could not copy — open the full ask and copy it by hand.');
300
+ });
301
+ }
302
+
216
303
  function toggleMore(btn) {
217
304
  const key = btn.id.replace(/^cb-|-more$/g, '');
218
305
  const host = $(`#cb-${key}`);
@@ -350,6 +437,10 @@
350
437
  const closest = (sel) => (ev.target && ev.target.closest ? ev.target.closest(sel) : null);
351
438
  const more = closest('.cb-more');
352
439
  if (more) { ev.preventDefault(); toggleMore(more); return; }
440
+ const door = closest('[data-ask-toggle]');
441
+ if (door) { ev.preventDefault(); toggleAsk(door); return; }
442
+ const copyBtn = closest('[data-ask-copy]');
443
+ if (copyBtn) { ev.preventDefault(); copyAsk(copyBtn); return; }
353
444
  const mingleBtn = closest('[data-mingle]');
354
445
  if (mingleBtn) {
355
446
  ev.preventDefault();
@@ -42,6 +42,12 @@ const VALID_KINDS = [
42
42
  // ratify endpoint (assert kind='criterion-proposal' before promotion).
43
43
  // CHECK constraint updated in migration 042.
44
44
  'criterion-proposal',
45
+ // task 1003105 — `/planning-session` and `/priority-session` both instruct the
46
+ // agent to file a retrospective finding as kind='skill-friction', and both
47
+ // failed BAD_KIND until now. The DB CHECK constraint is widened to match in
48
+ // migrations/core_244_skill_friction_kind.sql, which also
49
+ // explains why the legacy title-prefix marker below stays alongside it.
50
+ 'skill-friction',
45
51
  ];
46
52
  const VALID_STATUSES = ['open', 'promoted', 'discarded', 'merged'];
47
53
 
@@ -121,45 +127,13 @@ function ungradedProjection() {
121
127
  }
122
128
 
123
129
  // -------------------------------------------------------------------------
124
- // Skill-friction tagging (task V3.R70 / #289)
125
- //
126
- // The nightly `skill-usage-review` routine files one idea_inbox row per
127
- // friction pattern it detects. The done-when calls these "kind='skill-friction'"
128
- // rows, but the DB CHECK constraint on idea_inbox.kind (migration 006) only
129
- // admits the fixed task-kind enum — adding a literal 'skill-friction' kind
130
- // would need a new migration, and migrations are explicitly out of scope for
131
- // this task. So we tag friction rows the same way overnight-code-review tags
132
- // its findings: a stable title prefix. The underlying `kind` stays a valid
133
- // enum value (cleanup), and SKILL_FRICTION_TAG is the machine-checkable marker.
134
- //
135
- // When the GDS-V3 rank system (V3.R04) lands a real builders.rank column +
136
- // idea_inbox.kind migration, this prefix convention can be promoted to a
137
- // first-class kind value and this marker collapses to a kind check.
138
- const SKILL_FRICTION_TAG = '[skill-friction]';
139
-
140
- // -------------------------------------------------------------------------
141
- // Rank visibility (task V3.R70 / #289)
142
- //
143
- // Three ranks are planned in GDS-V3 (V3.R04): Xenos (default for any
144
- // GitHub-authed person), Metic (Archon-approved), Archon. Skill-friction
145
- // ideas are a system-evolution signal meant for trusted builders — Xenos
146
- // must NOT see them in their inbox view; Metic and Archon may.
147
- //
148
- // `builders.rank` does not exist in the schema yet (it lands with R04), and
149
- // requireBuilder does not yet populate req.builder.rank — so today every
150
- // caller passes viewerRank=null. The gate is written to be correct the moment
151
- // rank is threaded through: it hides skill-friction rows ONLY when the viewer
152
- // is positively known to be Xenos. Unknown/absent rank (today's reality, where
153
- // no Xenos exist yet) is treated as Metic+ and sees everything — so wiring this
154
- // in does not regress current all-builders behavior.
155
- const XENOS_RANK = 'xenos';
156
-
157
- function rankCanSeeSkillFriction(viewerRank) {
158
- // Hide only when we KNOW the viewer is Xenos. null/undefined/anything else
159
- // (Metic, Archon, or "rank system not deployed") => allowed.
160
- if (viewerRank == null) return true;
161
- return String(viewerRank).toLowerCase() !== XENOS_RANK;
162
- }
130
+ // Skill-friction: the marker a row carries and the rank gate that hides it.
131
+ // Pure, and its own file (./skill-friction.js) for the reason written there —
132
+ // re-exported below so every existing caller keeps working.
133
+ const {
134
+ SKILL_FRICTION_TAG, SKILL_FRICTION_KIND, XENOS_RANK,
135
+ rankCanSeeSkillFriction, skillFrictionHiddenClause,
136
+ } = require('./skill-friction');
163
137
 
164
138
  // -------------------------------------------------------------------------
165
139
  // Fallback classifier — keyword heuristics over title+body.
@@ -511,9 +485,8 @@ async function routeIdeaToGoal({
511
485
  // see skill-friction rows; Metic+ (or unknown rank, pre-R04) see everything.
512
486
  // We filter in SQL when the viewer is positively Xenos so the LIMIT still
513
487
  // returns up to `limit` *visible* rows rather than `limit` rows that then get
514
- // thinned out in JS. The skill-friction marker is a title prefix (see
515
- // SKILL_FRICTION_TAG above) because the kind enum cannot take a new value
516
- // without a migration.
488
+ // thinned out in JS. The marker is a kind OR a legacy title prefix — see
489
+ // skillFrictionHiddenClause, which is the only place that knows that.
517
490
  async function listOpenIdeas({ kind = null, limit = 50, viewerRank = null } = {}) {
518
491
  const params = [];
519
492
  const where = [`i.status = 'open'`];
@@ -521,10 +494,8 @@ async function listOpenIdeas({ kind = null, limit = 50, viewerRank = null } = {}
521
494
  params.push(kind);
522
495
  where.push(`i.kind = $${params.length}`);
523
496
  }
524
- if (!rankCanSeeSkillFriction(viewerRank)) {
525
- params.push(`${SKILL_FRICTION_TAG}%`);
526
- where.push(`i.title NOT LIKE $${params.length}`);
527
- }
497
+ const hideFriction = skillFrictionHiddenClause(viewerRank, params, 'i');
498
+ if (hideFriction) where.push(hideFriction);
528
499
  params.push(limit);
529
500
  const { rows } = await pool.query(
530
501
  `SELECT ${IDEA_COLS_Q}, ${GRADE_COLS_Q}
@@ -570,10 +541,8 @@ async function getIdea(id) {
570
541
  async function countOpenIdeas({ viewerRank = null } = {}) {
571
542
  const params = [];
572
543
  const where = [`status = 'open'`];
573
- if (!rankCanSeeSkillFriction(viewerRank)) {
574
- params.push(`${SKILL_FRICTION_TAG}%`);
575
- where.push(`title NOT LIKE $${params.length}`);
576
- }
544
+ const hideFriction = skillFrictionHiddenClause(viewerRank, params);
545
+ if (hideFriction) where.push(hideFriction);
577
546
  const { rows } = await pool.query(
578
547
  `SELECT count(*)::int AS n FROM idea_inbox WHERE ${where.join(' AND ')}`,
579
548
  params
@@ -619,10 +588,8 @@ const IDEA_TRAIL_MAX_LIMIT = 100;
619
588
  function ideaTrailWhere(builderId, viewerRank, params) {
620
589
  params.push(builderId);
621
590
  const where = [`i.captured_by = $${params.length}`];
622
- if (!rankCanSeeSkillFriction(viewerRank)) {
623
- params.push(`${SKILL_FRICTION_TAG}%`);
624
- where.push(`i.title NOT LIKE $${params.length}`);
625
- }
591
+ const hideFriction = skillFrictionHiddenClause(viewerRank, params, 'i');
592
+ if (hideFriction) where.push(hideFriction);
626
593
  return where.join(' AND ');
627
594
  }
628
595
 
@@ -1029,10 +996,8 @@ async function countRoutedAwaitingNod(deps = {}) {
1029
996
  // agreeing by construction beats them agreeing by accident.
1030
997
  function sparkQueueWhere(viewerRank, params) {
1031
998
  const where = [`g.grade = 'spark'`, `i.status = 'open'`];
1032
- if (!rankCanSeeSkillFriction(viewerRank)) {
1033
- params.push(`${SKILL_FRICTION_TAG}%`);
1034
- where.push(`i.title NOT LIKE $${params.length}`);
1035
- }
999
+ const hideFriction = skillFrictionHiddenClause(viewerRank, params, 'i');
1000
+ if (hideFriction) where.push(hideFriction);
1036
1001
  return where;
1037
1002
  }
1038
1003
 
@@ -1455,10 +1420,13 @@ module.exports = {
1455
1420
  DEFAULT_TEMPLATE_KEY,
1456
1421
  TEMPLATE_KEY_MAX,
1457
1422
  FIELD_VALUES_MAX,
1458
- // skill-friction tagging + rank visibility gate (V3.R70 / #289)
1423
+ // skill-friction marker + rank visibility gate (V3.R70 / #289; the kind
1424
+ // itself landed in task 1003105 — both markers are live, see above)
1459
1425
  SKILL_FRICTION_TAG,
1426
+ SKILL_FRICTION_KIND,
1460
1427
  XENOS_RANK,
1461
1428
  rankCanSeeSkillFriction,
1429
+ skillFrictionHiddenClause,
1462
1430
  // capture
1463
1431
  captureIdea,
1464
1432
  // read
@@ -0,0 +1,97 @@
1
+ // modules/ideas/skill-friction.js — the skill-friction marker and the rank
2
+ // gate that hides those rows, extracted from inbox.js (task 1003105).
3
+ //
4
+ // WHY ITS OWN FILE. Two reasons, and the second is the real one.
5
+ //
6
+ // `inbox.js` sits against the repo's per-file size budget, and adding the kind
7
+ // plus the reasoning for keeping the legacy marker pushed it over — the fitness
8
+ // ratchet's whole job. Trimming the explanation to fit would have deleted the
9
+ // part a later reader needs most: WHY there are two markers rather than one.
10
+ //
11
+ // And this was always a separable concern. It is pure — no pool, no I/O, no
12
+ // knowledge of a query beyond emitting a fragment — which is the same test
13
+ // `./routing.js` and `./homeless-home.js` already passed when they were split
14
+ // out of the same file. `inbox.js` re-exports everything here, so nothing that
15
+ // reached these through it has to change.
16
+
17
+ 'use strict';
18
+
19
+ // Skill-friction tagging (task V3.R70 / #289)
20
+ //
21
+ // A skill-friction row carries TWO markers, both first-class, both permanent.
22
+ //
23
+ // The nightly `skill-usage-review` routine files one idea_inbox row per
24
+ // friction pattern it detects, and it has always worked — by tagging a stable
25
+ // title prefix over a valid enum `kind`, because the CHECK constraint on
26
+ // idea_inbox.kind admitted no such value. That workaround named its own exit
27
+ // condition, here and in the routine's SKILL.md: promote the prefix to a real
28
+ // kind once the rank system lands a `builders.rank` column and a migration.
29
+ //
30
+ // Both have landed — rank is live and enforced server-side (ADR 0016), and
31
+ // `viewerRank` reaches all four read paths below from `req.builder.rank` — so
32
+ // task 1003105 added the kind (core_244_skill_friction_kind.sql). What it did
33
+ // NOT do is retire the prefix, and that is the part worth reading twice: the
34
+ // deployed core pin runs behind `main` BY DESIGN (gate 3 of the release
35
+ // pipeline), so there is a window where the database accepts the new kind and
36
+ // the running instance does not yet offer it. A filer flipped inside that
37
+ // window starts failing where it currently succeeds. So the prefix is not a
38
+ // deprecated alias to be swept later — it is the marker every historical row
39
+ // carries and the one the nightly routine still writes, and
40
+ // `skillFrictionHiddenClause` below is the single place that knows both.
41
+ const SKILL_FRICTION_TAG = '[skill-friction]';
42
+ const SKILL_FRICTION_KIND = 'skill-friction';
43
+
44
+ // -------------------------------------------------------------------------
45
+ // Rank visibility (task V3.R70 / #289)
46
+ //
47
+ // Three ranks are planned in GDS-V3 (V3.R04): Xenos (default for any
48
+ // GitHub-authed person), Metic (Archon-approved), Archon. Skill-friction
49
+ // ideas are a system-evolution signal meant for trusted builders — Xenos
50
+ // must NOT see them in their inbox view; Metic and Archon may.
51
+ //
52
+ // `builders.rank` does not exist in the schema yet (it lands with R04), and
53
+ // requireBuilder does not yet populate req.builder.rank — so today every
54
+ // caller passes viewerRank=null. The gate is written to be correct the moment
55
+ // rank is threaded through: it hides skill-friction rows ONLY when the viewer
56
+ // is positively known to be Xenos. Unknown/absent rank (today's reality, where
57
+ // no Xenos exist yet) is treated as Metic+ and sees everything — so wiring this
58
+ // in does not regress current all-builders behavior.
59
+ const XENOS_RANK = 'xenos';
60
+
61
+ function rankCanSeeSkillFriction(viewerRank) {
62
+ // Hide only when we KNOW the viewer is Xenos. null/undefined/anything else
63
+ // (Metic, Archon, or "rank system not deployed") => allowed.
64
+ if (viewerRank == null) return true;
65
+ return String(viewerRank).toLowerCase() !== XENOS_RANK;
66
+ }
67
+
68
+ // skillFrictionHiddenClause — the ONE predicate meaning "this row is not a
69
+ // skill-friction row", for the four read paths that hide them from a Xenos.
70
+ // It tests BOTH markers (see SKILL_FRICTION_TAG above).
71
+ //
72
+ // It exists as a function rather than four copied conditions because the four
73
+ // gates have to agree and nothing made them: a copy that learned about the new
74
+ // kind while its siblings did not would leak skill-friction rows into exactly
75
+ // one Xenos-facing listing, which is the kind of gap nobody notices until it is
76
+ // in front of the wrong reader. Filtering stays in SQL so a LIMIT still returns
77
+ // up to `limit` VISIBLE rows rather than `limit` rows thinned out afterwards.
78
+ //
79
+ // Pushes its own params onto `params`. Returns null when the viewer may see
80
+ // everything — callers push the clause only when it is non-null, so a viewer
81
+ // with no rank restriction costs no bind parameters at all.
82
+ function skillFrictionHiddenClause(viewerRank, params, alias = '') {
83
+ if (rankCanSeeSkillFriction(viewerRank)) return null;
84
+ const col = alias ? `${alias}.` : '';
85
+ params.push(SKILL_FRICTION_KIND);
86
+ const kindParam = params.length;
87
+ params.push(`${SKILL_FRICTION_TAG}%`);
88
+ return `(${col}kind <> $${kindParam} AND ${col}title NOT LIKE $${params.length})`;
89
+ }
90
+
91
+ module.exports = {
92
+ SKILL_FRICTION_TAG,
93
+ SKILL_FRICTION_KIND,
94
+ XENOS_RANK,
95
+ rankCanSeeSkillFriction,
96
+ skillFrictionHiddenClause,
97
+ };
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.794",
3
+ "version": "1.19.796",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.794",
9
+ "version": "1.19.796",
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.794",
3
+ "version": "1.19.796",
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",
@@ -16,8 +16,12 @@
16
16
  // package; this module copies+transforms them when they ARE present at the core root (today's single
17
17
  // checkout, and the core repo itself), and no-ops with a note when they aren't (an installed package).
18
18
  // Hooks self-resolve their core require()s at RUNTIME via the copied _core-resolve.js sibling
19
- // (task 2139, the former W3 follow-up), so no require-path rewriting happens here; shipping a
20
- // topology STARTER for a package-only install (hooks absent at the core root) stays a follow-up (task 1883).
19
+ // (task 2139, the former W3 follow-up), so no require-path rewriting happens here. The topology
20
+ // STARTER for a package-only install is now HALF built: settings.json is written from
21
+ // renderStarterSettings() below — permissions only, no hooks — because an instance that received
22
+ // the skills but no permissions met the classifier on every blessed command (task 1003962). A hooks
23
+ // starter remains a follow-up (task 1883), and is a different question: hooks ARE gate machinery,
24
+ // where a list of allowed command names is not.
21
25
  //
22
26
  // SELF-COPY GUARD. When the instance dir IS the core root (running init inside the core checkout /
23
27
  // OTB today), there is nothing to materialize — a self-copy would be a no-op at best and corrupting
@@ -296,6 +300,50 @@ function copyTreeTransformed(srcDir, destDir, { dryRun, onFile, overwrite = true
296
300
 
297
301
  // Transform settings.json: parse, rewrite every hook command through bongosify, re-serialize. Keeps
298
302
  // .claude/hooks/*.js commands (instance-local) untouched and rewrites the scripts/gds/*.js ones.
303
+ // The blessed commands an instance builder runs to do the methodology, one rule
304
+ // each (task 1003962). This MIRRORS the core repo's own `.claude/settings.json`
305
+ // allow-list (ADR 0104 / task 1051), translated from `node scripts/gds/<x>.js`
306
+ // into the verb form a materialized instance actually invokes — bongosify()
307
+ // rewrites every skill to `bongos <verb>`, so a path-shaped rule would match
308
+ // nothing there.
309
+ //
310
+ // THE GAP THIS CLOSES. `.claude/settings.json` is excluded from the published
311
+ // package on purpose (PROTECTED_TOPOLOGY_EXCLUDE, beside infra/ and .github/):
312
+ // the core's own copy is deploy and gate machinery that means nothing on an
313
+ // instance. But the skills ship, so an instance received the methodology and
314
+ // none of the permissions that let it run — every instance builder met the
315
+ // auto-mode classifier on the normal workflow, which is how a live builder came
316
+ // to be stuck mid-re-auth with no way forward.
317
+ //
318
+ // WHY THIS IS NOT AN AUTHORITY GRANT (ADR 0016). An allow-rule stops the local
319
+ // agent REFUSING to run a command; it changes nothing about what the server
320
+ // permits. Rank is read fresh from the database on every request by
321
+ // `requireRank`, so a builder who edits this file grants themselves exactly
322
+ // nothing. That is the distinction that makes shipping a list of command names
323
+ // safe where shipping hooks — actual local gate machinery — is not.
324
+ //
325
+ // DELIBERATELY ABSENT: `bongos exec`. It runs any script inside the core by
326
+ // path and is the agent's escape hatch, so allow-listing it would be a blanket
327
+ // grant wearing a narrow rule's clothes. The one script the core's own list
328
+ // blesses (`api.js`) is named in full instead.
329
+ const STARTER_ALLOW = [
330
+ 'Bash(bongos claim:*)',
331
+ 'Bash(bongos ship:*)',
332
+ 'Bash(bongos release:*)',
333
+ 'Bash(bongos reauth:*)',
334
+ 'Bash(bongos paste-token:*)',
335
+ 'Bash(bongos exec scripts/gds/api.js:*)',
336
+ ];
337
+
338
+ // The starter an instance gets when the core root carries no settings.json — a
339
+ // package-only install. Permissions ONLY: no hooks (they are topology), no env.
340
+ function renderStarterSettings() {
341
+ return `${JSON.stringify({
342
+ _doc: 'Starter written by scripts/gds/claude-materialize.js (task 1003962). Permissions only — the blessed Bongos commands, mirroring the core repo\'s own allow-list (ADR 0104). An allow-rule lets the local agent RUN a command; it grants no authority, which the server decides per request from your live rank (ADR 0016). Yours to edit: a later materialize never clobbers this file.',
343
+ permissions: { allow: STARTER_ALLOW },
344
+ }, null, 2)}\n`;
345
+ }
346
+
299
347
  function transformSettings(srcPath) {
300
348
  const raw = fs.readFileSync(srcPath, 'utf8');
301
349
  let json;
@@ -484,7 +532,25 @@ function materializeClaude({ coreRoot, instanceDir, dryRun = false, overwrite =
484
532
  log(` ${dryRun ? '[dry-run] would write' : '✓ wrote'} .claude/settings.json (hook commands→bongos, identity rebranded)`);
485
533
  }
486
534
  } else {
487
- summary.notes.push('.claude/settings.json not present at the core root (topology, excluded from the package) — provide it from a starter.');
535
+ // Package-only install: the core root carries no settings.json to copy, so
536
+ // write the STARTER instead of leaving the instance with no permissions at
537
+ // all (task 1003962 — the note that used to sit here said "provide it from a
538
+ // starter" and nothing ever did). Written only when ABSENT, the same rule
539
+ // the starter CLAUDE.md below follows: an instance that has grown its own
540
+ // permissions owns them, and a later materialize must never clobber them.
541
+ const settingsDest = path.join(destClaude, 'settings.json');
542
+ if (fs.existsSync(settingsDest)) {
543
+ summary.skippedFiles.push('settings.json');
544
+ log(' • .claude/settings.json already present — left untouched (the instance owns it).');
545
+ } else {
546
+ if (!dryRun) {
547
+ fs.mkdirSync(destClaude, { recursive: true });
548
+ fs.writeFileSync(settingsDest, renderStarterSettings());
549
+ }
550
+ summary.settings = true;
551
+ summary.notes.push(`.claude/settings.json written from the starter — ${STARTER_ALLOW.length} blessed command(s), permissions only.`);
552
+ log(` ${dryRun ? '[dry-run] would write' : '✓ wrote'} .claude/settings.json (starter: ${STARTER_ALLOW.length} blessed commands, no hooks)`);
553
+ }
488
554
  }
489
555
 
490
556
  // 3. hooks — topology; copied WITH an identity rebrand (their .js internals carried
@@ -650,4 +716,4 @@ if (require.main === module) {
650
716
  process.exit(s.refused.length ? 1 : 0);
651
717
  }
652
718
 
653
- 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 };
719
+ module.exports = { scriptToVerb, bongosify, transformSettings, renderStarterSettings, STARTER_ALLOW, 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 };
@@ -9,7 +9,7 @@
9
9
  'use strict';
10
10
 
11
11
  const { cliClient, requireSession, arg, detectWorktreeName, cardFlagsFromMe } = require('./cli-lib');
12
- const { resolveProse, markShipProgress, smokeGateMessages, proseFlagPresent, regradeNotesRefusal } = require('./ship-honesty.js');
12
+ const { resolveProse, markShipProgress, smokeGateMessages, proseFlagPresent, regradeNotesRefusal, staleSummaryNotice } = require('./ship-honesty.js');
13
13
  const { classifyEmptyShip } = require('../../modules/lifecycle/ship-preflight');
14
14
  const { scopeForModule, isModuleKey } = require('../../src/bongos/module-scope-map');
15
15
  const { matchOne } = require('../../src/bongos/path-match');
@@ -826,6 +826,12 @@ async function regradeMain() {
826
826
  // PATCH /tasks/:id is the route it uses, and `value_summary` was added to that
827
827
  // schema by this same task. tests/ship_cannot_lie.mjs pins the wiring.
828
828
  const summary = summaryArg || task.value_summary || '';
829
+ // task 1003693: the fallback above is silent, and on a re-grade silence means
830
+ // re-publishing the failed attempt's sentence. Say so while it can still be
831
+ // corrected — see staleSummaryNotice for the incident this comes from.
832
+ if (!summaryArg) {
833
+ for (const line of staleSummaryNotice(taskId, task.value_summary)) console.log(line);
834
+ }
829
835
 
830
836
  // The grader helpers want a claim-like object for touches[] (preflight's
831
837
  // working-tree-drift check) and head_sha (baseline fallback). The claim is