@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.
- package/.bongos-core.json +39 -24
- package/docs/copy-inventory.md +12 -10
- package/docs/copy-registry.json +30 -12
- package/docs/module-api-changelog.md +4 -0
- package/migrations/core_244_skill_friction_kind.sql +74 -0
- package/modules/hall-ui/public/collab-lib.js +64 -1
- package/modules/hall-ui/public/collab.css +56 -0
- package/modules/hall-ui/public/collab.html +2 -2
- package/modules/hall-ui/public/collab.js +108 -17
- package/modules/ideas/inbox.js +27 -59
- package/modules/ideas/skill-friction.js +97 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/claude-materialize.js +70 -4
- package/scripts/gds/ship-flow.js +7 -1
- package/scripts/gds/ship-honesty.js +55 -1
- package/src/module-api.js +1 -1
- package/tests/claude_materialize.mjs +71 -0
- package/tests/hall_collab_world.mjs +114 -3
- package/tests/ship_cannot_lie.mjs +75 -1
- package/tests/skill_friction_kind.mjs +163 -0
|
@@ -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-
|
|
102
|
-
<script src="/builders/collab.js?v=2026-09-16-
|
|
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
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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">${
|
|
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">${
|
|
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">${
|
|
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();
|
package/modules/ideas/inbox.js
CHANGED
|
@@ -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
|
|
125
|
-
//
|
|
126
|
-
//
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
|
515
|
-
//
|
|
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
|
-
|
|
525
|
-
|
|
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
|
-
|
|
574
|
-
|
|
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
|
-
|
|
623
|
-
|
|
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
|
-
|
|
1033
|
-
|
|
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
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
20
|
-
//
|
|
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
|
-
|
|
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 };
|
package/scripts/gds/ship-flow.js
CHANGED
|
@@ -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
|