@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.
@@ -195,9 +195,63 @@ function regradeNotesRefusal(taskId) {
195
195
  ];
196
196
  }
197
197
 
198
+ // THE FOURTH WAY TO LOSE THE BUILDER'S PROSE — and the only one where what
199
+ // survives is worse than nothing, because it is a sentence that was true once.
200
+ //
201
+ // regradeMain falls back to the stored value_summary when --summary is omitted
202
+ // (`summaryArg || task.value_summary`), and persistRegradeSummary deliberately
203
+ // writes nothing on an omission. Both postures are right alone: a re-grade with
204
+ // no summary must not blank the row, and an empty broadcast would be worse than
205
+ // a stale one. Together they mean the DEFAULT re-grade re-publishes the first
206
+ // attempt's sentence with nothing saying so.
207
+ //
208
+ // That is the task-1002343 incident exactly. Shipped 2026-09-04 asserting "No
209
+ // project was created and nothing on the live system was left changed"; parked
210
+ // at completed on a failing grade; redone 2026-09-07, when a project WAS created
211
+ // (instance 40, r24-proof). The re-grade omitted --summary, so the ledger and the
212
+ // #ship-news post both announced the opposite of what landed. Nobody ignored a
213
+ // correction — there was no prompt to make one.
214
+ //
215
+ // So this is a NOTICE, not a refusal and not a behaviour change. The fallback is
216
+ // correct and stays; task 1003817 already built the correction path (--summary on
217
+ // a re-grade now reaches the row, the hall, /status and the broadcast). What was
218
+ // missing is the moment that tells the builder that path exists, while the run
219
+ // can still be corrected. Printed BEFORE the grade spend for the reason the
220
+ // --notes refusal is: a warning that first costs a 4-worker panel run is barely
221
+ // a warning at all.
222
+ //
223
+ // Returns [] when the row holds no stored summary — there is no stale text to
224
+ // warn about then, and an empty summary is the merge leg's problem, not this
225
+ // one. Pure, so the WORDING is testable: a notice that fails to name the fix is
226
+ // noise with a warning sign on it.
227
+ function staleSummaryNotice(taskId, storedSummary) {
228
+ // Control characters are stripped BEFORE the whitespace collapse, which only
229
+ // matches \s and would leave an ESC through. The stored summary is builder prose
230
+ // that has already been server-side stored and is already bound for Discord and
231
+ // the hall, so this crosses no new trust boundary — but this is the one place it
232
+ // reaches a TERMINAL, where an escape sequence could repaint the notice around it.
233
+ const stored = String(storedSummary || '')
234
+ .replace(/[\u0000-\u001f\u007f-\u009f]/g, ' ')
235
+ .replace(/\s+/g, ' ')
236
+ .trim();
237
+ if (!stored) return [];
238
+ const shown = stored.length > 140 ? `${stored.slice(0, 139)}…` : stored;
239
+ return [
240
+ '⚠ No --summary given — this re-grade reuses the summary stored at the FIRST ship:',
241
+ ` "${shown}"`,
242
+ ' That sentence describes the attempt that FAILED its grade. A re-grade usually means the',
243
+ ' work changed since, and this text is what the panel reads, what the merge commit and PR',
244
+ ' say, and what goes to Discord #ship-news as the durable record of this ship.',
245
+ '',
246
+ ' If it still describes what landed, ignore this. If not, correct it in this same run:',
247
+ ` node scripts/gds/ship.js ${taskId} --regrade --summary-file summary.txt`,
248
+ ' (the -file form is the safe one for prose — a shell eats backticks in an inline value)',
249
+ ];
250
+ }
251
+
198
252
  module.exports = {
199
253
  resolveProse, markShipProgress, fatalRecoveryLines, smokeGateMessages,
200
- proseFlagPresent, regradeNotesRefusal,
254
+ proseFlagPresent, regradeNotesRefusal, staleSummaryNotice,
201
255
  _resetForTest: () => { shipProgress = { stage: 'preflight', taskId: null }; },
202
256
  _peekForTest: () => shipProgress,
203
257
  };
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.794'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
74
+ const CORE_VERSION = '1.19.796'; // 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');
@@ -26,6 +26,9 @@ const require = createRequire(import.meta.url);
26
26
  const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
27
27
  const m = require('../scripts/gds/claude-materialize.js');
28
28
  const claim = require('../scripts/gds/claim.js');
29
+ // The live verb table, so a starter allow-rule naming a verb that does not exist
30
+ // fails here rather than shipping as an inert permission (task 1003962).
31
+ const { VERBS: cliVerbs } = require('../bin/bongos.js');
29
32
 
30
33
  let passed = 0;
31
34
  let failed = 0;
@@ -704,5 +707,73 @@ t('materializeModeSkills lands the real module-owned skills, self-ignored, and i
704
707
  rmSync(core, { recursive: true, force: true });
705
708
  });
706
709
 
710
+ // ---------------------------------------------------------------------------
711
+ // task 1003962 — the starter settings.json for a package-only install.
712
+ //
713
+ // THE GAP: `.claude/skills/` ships in the core package but `.claude/settings.json`
714
+ // is excluded as topology, so an instance received the methodology and none of the
715
+ // permissions that let it run — every blessed command met the auto-mode classifier.
716
+ // ---------------------------------------------------------------------------
717
+
718
+ t('the starter carries PERMISSIONS ONLY — no hooks, no env (those are topology)', () => {
719
+ const parsed = JSON.parse(m.renderStarterSettings());
720
+ assert.ok(Array.isArray(parsed.permissions.allow), 'must carry permissions.allow');
721
+ assert.ok(!('hooks' in parsed), 'hooks are gate machinery and must NOT ship to an instance');
722
+ assert.ok(!('env' in parsed), 'env is topology and must NOT ship to an instance');
723
+ assert.ok(parsed._doc.includes('grants no authority'),
724
+ 'the file must say out loud that it grants no authority (ADR 0016)');
725
+ });
726
+
727
+ t('every allow-rule names a verb the CLI actually registers', () => {
728
+ // The drift this catches: a rule naming a verb that was renamed or never existed
729
+ // is silently inert — it looks like a permission and grants nothing, so the
730
+ // builder still meets the classifier and nobody can see why.
731
+ for (const rule of m.STARTER_ALLOW) {
732
+ const inner = rule.replace(/^Bash\(/, '').replace(/:\*\)$/, '');
733
+ const verb = inner.split(' ')[1];
734
+ assert.ok(cliVerbs[verb], `allow-rule "${rule}" names "${verb}", which is not a registered bongos verb`);
735
+ }
736
+ });
737
+
738
+ t('the escape hatch is NOT blanket-allowed', () => {
739
+ // `bongos exec` runs any script inside the core by path. Allow-listing it bare
740
+ // would be a blanket grant wearing a narrow rule's clothes.
741
+ assert.ok(!m.STARTER_ALLOW.includes('Bash(bongos exec:*)'),
742
+ 'bongos exec must never be allowed bare');
743
+ const execRules = m.STARTER_ALLOW.filter((r) => r.includes('bongos exec'));
744
+ for (const r of execRules) {
745
+ assert.match(r, /bongos exec scripts\/gds\/[a-z-]+\.js/,
746
+ `an exec rule must name its script in full, got: ${r}`);
747
+ }
748
+ });
749
+
750
+ t('a package-only install (no settings.json at the core root) GETS the starter', () => {
751
+ const core = mkdtempSync(join(tmpdir(), 'w3-starter-core-'));
752
+ const inst = mkdtempSync(join(tmpdir(), 'w3-starter-inst-'));
753
+ mkdirSync(join(core, '.claude', 'skills'), { recursive: true }); // skills ship; settings.json does not
754
+ const res = m.materializeClaude({ coreRoot: core, instanceDir: inst, dryRun: false });
755
+ assert.equal(res.settings, true, 'materialize should report it wrote settings');
756
+ const written = join(inst, '.claude', 'settings.json');
757
+ assert.ok(existsSync(written), 'instance must receive a starter settings.json');
758
+ const parsed = JSON.parse(readFileSync(written, 'utf8'));
759
+ assert.deepEqual(parsed.permissions.allow, m.STARTER_ALLOW);
760
+ rmSync(core, { recursive: true, force: true });
761
+ rmSync(inst, { recursive: true, force: true });
762
+ });
763
+
764
+ t("an instance's OWN settings.json is never clobbered", () => {
765
+ const core = mkdtempSync(join(tmpdir(), 'w3-keep-core-'));
766
+ const inst = mkdtempSync(join(tmpdir(), 'w3-keep-inst-'));
767
+ mkdirSync(join(core, '.claude', 'skills'), { recursive: true });
768
+ mkdirSync(join(inst, '.claude'), { recursive: true });
769
+ const mine = JSON.stringify({ permissions: { allow: ['Bash(echo hello:*)'] } }, null, 2);
770
+ writeFileSync(join(inst, '.claude', 'settings.json'), mine);
771
+ m.materializeClaude({ coreRoot: core, instanceDir: inst, dryRun: false });
772
+ assert.equal(readFileSync(join(inst, '.claude', 'settings.json'), 'utf8'), mine,
773
+ 'an upgrade must never overwrite permissions the instance has grown');
774
+ rmSync(core, { recursive: true, force: true });
775
+ rmSync(inst, { recursive: true, force: true });
776
+ });
777
+
707
778
  console.log(`\nclaude_materialize: ${passed} passed, ${failed} failed`);
708
779
  process.exit(failed ? 1 : 0);
@@ -123,10 +123,121 @@ test('the first line is decided: the ask\'s first sentence is the title, the res
123
123
  assert.equal(L.titleOf('docs/x.md is stale and y.js too').title, 'docs/x.md is stale and y.js too', 'a dot inside a path is not a sentence break');
124
124
  const t = L.titleOf(' padded. and trailing ');
125
125
  assert.equal(`${t.title} ${t.rest}`, 'padded. and trailing', 'nothing is clipped');
126
+ // titleOf is still the sentence splitter; what the page DOES with the tail
127
+ // changed in task 1003960 and is asserted in the next test.
128
+ assert.equal(typeof L.summaryOf, 'function', 'the page face is built on summaryOf now');
129
+ });
130
+
131
+ test('a card face carries two sentences at most, and the whole ask waits behind a door', () => {
132
+ const L = lib();
133
+
134
+ // The common case: two short sentences fit, so there is nothing behind a door
135
+ // and the page must not offer one. A door onto nothing is a lie about there
136
+ // being more to read.
137
+ const fits = L.summaryOf('The pin will not move past 782. Can you look before I ship?');
138
+ assert.deepEqual(fits, {
139
+ title: 'The pin will not move past 782.',
140
+ lead: 'Can you look before I ship?',
141
+ more: '',
142
+ });
143
+ assert.equal(L.summaryOf('Can you look at the pin?').more, '', 'one sentence needs no door');
144
+
145
+ // The case the owner reported: a real ask, written the way the ask-for-help
146
+ // skill asks for it, is five parts long. Two reach the face; the rest does not.
147
+ const long = [
148
+ 'The deploy pin is stuck at 1.19.782.',
149
+ 'I need a Metic because the release gate is above my rank.',
150
+ 'Run the release pipeline, then check /version reports 789.',
151
+ 'Verify by loading the hall and reading the footer.',
152
+ 'I already ruled out a stale CDN cache and a bad merge.',
153
+ ].join(' ');
154
+ const s = L.summaryOf(long);
155
+ assert.equal(s.title, 'The deploy pin is stuck at 1.19.782.');
156
+ assert.equal(s.lead, 'I need a Metic because the release gate is above my rank.');
157
+ assert.ok(s.more, 'the rest goes behind the door');
158
+ assert.equal(s.more, long, 'and what is behind the door is the WHOLE ask, so a copy is not a fragment');
159
+ assert.ok(!s.title.includes('Verify by loading'), 'nothing past the second sentence is on the face');
160
+
161
+ // A sentence count alone is not enough: one 4000-character paragraph with no
162
+ // full stop is ONE sentence, and counting sentences would put all of it on the
163
+ // face. The line budget catches it, and the ask still goes behind the door whole.
164
+ const blob = 'x '.repeat(900).trim();
165
+ const b = L.summaryOf(blob);
166
+ assert.ok(b.title.length < 160, 'a blob is cut to a lead-in');
167
+ assert.ok(b.title.endsWith('\u2026'), 'and the cut is marked');
168
+ assert.equal(b.lead, '');
169
+ assert.equal(b.more, blob, 'the words are not lost, only moved');
170
+
171
+ // The cut never lands mid-word.
172
+ const words = 'antidisestablishmentarianism '.repeat(40).trim();
173
+ const w = L.summaryOf(words);
174
+ assert.ok(/(^|\s)antidisestablishmentarianism\u2026$/.test(w.title) || w.title.endsWith('antidisestablishmentarianism\u2026'),
175
+ 'clipWords cuts at a space, never inside a word');
176
+
177
+ assert.deepEqual(L.summaryOf(''), { title: '', lead: '', more: '' }, 'an empty ask renders nothing, not a door');
178
+ });
179
+
180
+ test('the door tracks what is UNREAD, not the sentence count', () => {
181
+ const L = lib();
182
+
183
+ // Raised by the grader on task 1003960 as a violation of "one or two sentences
184
+ // shows no expander". It is neither a violation nor a bug, and the rule it
185
+ // appeals to was the imprecise one: an ask earns a door when part of it is not
186
+ // on the face, and sentence two can be longer than the face on its own.
187
+ const twoButLong = 'The pin will not move. ' + 'I have tried '.repeat(30).trim() + '.';
188
+ const r = L.summaryOf(twoButLong);
189
+ assert.equal(r.title, 'The pin will not move.');
190
+ assert.ok(r.lead.endsWith('\u2026'), 'the second sentence did not fit, so it was cut');
191
+ assert.ok(r.lead.length < twoButLong.length, 'and part of it is therefore unread');
192
+ assert.equal(r.more, twoButLong,
193
+ 'so it gets a door — hiding one here would abbreviate an ask with no way to read the rest');
194
+
195
+ // The mirror case, so the pair states the rule between them: two sentences
196
+ // that BOTH fit are fully on the face, so there is nothing to open.
197
+ assert.equal(L.summaryOf('The pin will not move. Can you look?').more, '',
198
+ 'nothing unread means no door');
199
+ });
200
+
201
+ test('the door and the copy control are on the row, and the copy control is reachable while it is shut', () => {
126
202
  const js = read('collab.js');
127
- assert.match(js, /L\.titleOf\(req\.what_is_stuck\)/);
128
- assert.match(js, /class="ov-row__title">\$\{escapeHtml\(t\.title\)\}/);
129
- assert.match(js, /class="ov-row__sub">\$\{escapeHtml\(t\.rest\)\}/);
203
+ assert.match(js, /L\.summaryOf\(req\.what_is_stuck\)/, 'the face is built from summaryOf');
204
+ assert.match(js, /class="ov-row__title">\$\{escapeHtml\(s\.title\)\}/);
205
+ assert.match(js, /class="ov-row__sub">\$\{escapeHtml\(s\.lead\)\}/);
206
+ assert.match(js, /if \(!s\.more\) return html;/, 'no door when the ask already fits');
207
+
208
+ // The door announces its state and names what it controls, so it works from
209
+ // the keyboard and reads correctly to a screen reader.
210
+ assert.match(js, /data-ask-toggle aria-expanded="false" aria-controls="\$\{fid\}"/);
211
+ assert.match(js, /aria-expanded', open \? 'false' : 'true'/, 'and the state flips when it opens');
212
+ assert.match(js, /Read the full ask/);
213
+ assert.match(js, /Hide the full ask/);
214
+
215
+ // The copy control sits in the SAME block as the door, which is rendered
216
+ // whether or not the ask is open — the owner asked for it "in the closed
217
+ // version next to the link", and a control only reachable after expanding
218
+ // would make a reader open four thousand characters to take them away.
219
+ const bar = js.slice(js.indexOf('<div class="cb-ask">'), js.indexOf('</div>', js.indexOf('<div class="cb-ask">')));
220
+ assert.match(bar, /data-ask-toggle/, 'the door is in the bar');
221
+ assert.match(bar, /Copy for Session Start/, 'and so is the copy control, in the owner\'s words');
222
+ assert.ok(!/hidden/.test(bar), 'the bar itself is never hidden');
223
+ assert.match(js, /<p class="cb-ask__full" id="\$\{fid\}" hidden>/, 'only the ask is hidden');
224
+
225
+ // What lands on the clipboard is the ask verbatim, line breaks and all.
226
+ assert.match(js, /full\.textContent/, 'copied from textContent, so the line breaks survive');
227
+ assert.ok(!/full\.innerText/.test(js), 'innerText on a hidden element is not guaranteed to keep them');
228
+ assert.match(js, /document\.execCommand\('copy'\)/, 'with the hall\'s fallback for an insecure context');
229
+
230
+ // One request can appear in three blocks at once, so the id is block-scoped.
231
+ assert.match(js, /function askId\(key, id\)/);
232
+ assert.match(js, /cb-ask-\$\{key\}-/);
233
+ assert.match(js, /rowFn\(r, nowMs, false, key\)/, 'and paintBlock hands the key down');
234
+
235
+ const css = read('collab.css');
236
+ for (const cls of ['.cb-ask ', '.cb-ask__door', '.cb-ask__copy', '.cb-ask__full']) {
237
+ assert.ok(css.includes(cls), `collab.css dresses ${cls.trim()}`);
238
+ }
239
+ assert.match(css, /\.cb-ask__full[\s\S]*?white-space: pre-wrap/, 'the opened ask keeps its own paragraphs');
240
+ assert.ok(!/\.cb-ask__copy[^}]*text-transform/.test(css), 'the copy control keeps the capitals the owner wrote');
130
241
  });
131
242
 
132
243
  test('"asked of you" reads in runs — by name first, then each craft', () => {
@@ -17,6 +17,10 @@
17
17
  //
18
18
  // (3) --regrade must not swallow --notes. Same shape as (1): prose handed to
19
19
  // ship.js that reaches nothing, silently (task 1003702).
20
+ //
21
+ // (4) --regrade must not silently REUSE the failed attempt's summary. The same
22
+ // silence as (3) from the other side: prose the builder never handed over,
23
+ // published as though they had (task 1003693).
20
24
  import { strict as assert } from 'node:assert';
21
25
  import { test } from 'node:test';
22
26
  import { createRequire } from 'node:module';
@@ -26,7 +30,7 @@ const require = createRequire(import.meta.url);
26
30
  const { argText } = require('../scripts/gds/cli-lib.js');
27
31
  const {
28
32
  fatalRecoveryLines, markShipProgress, _peekForTest,
29
- proseFlagPresent, regradeNotesRefusal,
33
+ proseFlagPresent, regradeNotesRefusal, staleSummaryNotice,
30
34
  } = require('../scripts/gds/ship-honesty.js');
31
35
 
32
36
  // Injected IO so no case touches the filesystem.
@@ -297,3 +301,73 @@ test('a re-grade that omits --summary leaves the stored value_summary alone', ()
297
301
  // And a failed write never aborts the re-grade — no process.exit, no throw.
298
302
  assert.ok(!/process\.exit/.test(code), 'a summary that will not persist must not kill the re-grade');
299
303
  });
304
+
305
+ // ── (4) an omitted --summary on a re-grade says so (task 1003693) ────────────
306
+ //
307
+ // The sibling of the ACCEPTANCE 2 test above. That one pins that an omitted
308
+ // --summary does not OVERWRITE the row; this one pins that it does not pass in
309
+ // SILENCE. Both postures were already right and the gap was between them: the
310
+ // default re-grade re-publishes the failed attempt's sentence to #ship-news
311
+ // with nothing saying so (task 1002343, 2026-09-07).
312
+
313
+ test('the stale-summary notice quotes the sentence and names the fix', () => {
314
+ const lines = staleSummaryNotice(1002343, 'No project was created and nothing on the live system was left changed.');
315
+ const text = lines.join('\n');
316
+ // The builder cannot judge a summary they are not shown — the whole point is
317
+ // that the stored sentence is plausible until you read it next to the work.
318
+ assert.match(text, /No project was created and nothing on the live system was left changed\./,
319
+ 'quotes the stored sentence verbatim');
320
+ // A notice that names no fix is noise with a warning sign on it.
321
+ assert.match(text, /--regrade --summary-file/, 'names the correcting flag');
322
+ assert.match(text, /ship\.js 1002343 --regrade/, 'as a runnable command with the real task id');
323
+ // WHY it matters: this text becomes the durable public record, not just a log line.
324
+ assert.match(text, /#ship-news/, 'says where the sentence ends up');
325
+ assert.match(text, /FAILED its grade/, 'says which attempt the stored sentence describes');
326
+ });
327
+
328
+ test('the notice stays silent when there is no stored summary to go stale', () => {
329
+ // No stored text means nothing can be re-published wrongly; an empty summary
330
+ // is the merge leg's problem. Warning here would train the builder to skip it.
331
+ for (const empty of ['', ' ', null, undefined]) {
332
+ assert.deepEqual(staleSummaryNotice(1, empty), [], `silent for ${JSON.stringify(empty)}`);
333
+ }
334
+ });
335
+
336
+ test('the quoted sentence is always one readable line', () => {
337
+ // value_summary allows up to LIMITS.VALUE_SUMMARY characters and may hold
338
+ // newlines. Either would break the quoted line out of the notice block and
339
+ // bury the runnable command that follows it.
340
+ const long = staleSummaryNotice(7, 'x'.repeat(400));
341
+ assert.ok(long[1].length < 160, 'a long summary is truncated');
342
+ assert.match(long[1], /…/, 'and says it was truncated');
343
+ const multi = staleSummaryNotice(9, 'line one\n\nline two\ttabbed');
344
+ assert.equal(multi[1].split('\n').length, 1, 'newlines collapse');
345
+ assert.match(multi[1], /"line one line two tabbed"/, 'into single spaces');
346
+
347
+ // This is the one place a stored summary reaches a TERMINAL. \s does not match
348
+ // ESC, so the collapse above would pass an escape sequence straight through and
349
+ // let it repaint the notice around itself.
350
+ const esc = staleSummaryNotice(11, 'safe\u001b[31mred\u0007 text');
351
+ assert.ok(!/[\u0000-\u001f\u007f-\u009f]/.test(esc.join(' ')),
352
+ 'terminal control characters are stripped before display');
353
+ assert.match(esc[1], /"safe \[31mred text"/, 'and the readable text survives');
354
+ });
355
+
356
+ test('the notice is wired into the regrade path, before any grade spend', () => {
357
+ // The (1) lesson again: a helper nothing calls is not a fix. And the ORDER is
358
+ // load-bearing for the same reason the --notes refusal's is — a warning the
359
+ // builder reads only after a 4-worker panel has run cannot change that run.
360
+ const src = readFileSync(new URL('../scripts/gds/ship-flow.js', import.meta.url), 'utf8');
361
+ const regrade = src.slice(src.indexOf('async function regradeMain'));
362
+ const code = regrade.replace(/\/\/[^\n]*/g, ''); // comments describe, they do not print
363
+
364
+ const call = code.indexOf('staleSummaryNotice(taskId, task.value_summary)');
365
+ assert.ok(call !== -1, 'regradeMain emits the notice');
366
+ // Keyed on the RAW flag, like persistRegradeSummary — `summary` has already
367
+ // absorbed the fallback by this point, so testing it would never be falsy.
368
+ // Brace-form or one-liner, but it must branch on summaryArg and nothing else.
369
+ assert.match(code, /if \(!summaryArg\)\s*\{?\s*(\n\s*)?for \(const line of staleSummaryNotice/,
370
+ 'it fires on the raw omitted flag, not the resolved fallback');
371
+ const preflight = code.indexOf('preflight(claimLike');
372
+ assert.ok(preflight !== -1 && call < preflight, 'and prints before the preflight and the panel');
373
+ });
@@ -0,0 +1,163 @@
1
+ // tests/skill_friction_kind.mjs
2
+ //
3
+ // 'skill-friction' as a real idea kind, and the one predicate that hides those
4
+ // rows from a Xenos (task 1003105, goal 1000095, from idea 1000759).
5
+ //
6
+ // Two things are pinned here, and they fail in different directions.
7
+ //
8
+ // THE DRIFT TRIPWIRE. `VALID_KINDS` in JS and the CHECK constraint in SQL are
9
+ // two lists that must say the same thing, and nothing made them. Widening one
10
+ // and not the other is silent in both directions: a kind added only to JS gets
11
+ // past validation and dies on INSERT with a constraint violation instead of a
12
+ // clean BAD_KIND, and a kind added only to SQL is unreachable. That is the exact
13
+ // shape of the bug this task fixed, so the fix ships with the tripwire.
14
+ //
15
+ // THE FOUR GATES. Skill-friction rows are hidden from Xenos-rank builders in
16
+ // four separate read paths. They used to be four hand-copied conditions testing
17
+ // a title prefix; now that the marker can ALSO be a kind, four copies would be
18
+ // four chances for one to learn about the kind and the others not to — and a
19
+ // gate that silently stops hiding is invisible until the wrong reader sees the
20
+ // row. So there is one predicate, and these tests hold it to both markers, to
21
+ // correct bind-parameter numbering, and to costing nothing for a viewer who may
22
+ // see everything.
23
+ //
24
+ // Pure: no DB, no network.
25
+ //
26
+ // Run: node tests/skill_friction_kind.mjs
27
+
28
+ import { strict as assert } from 'node:assert';
29
+ import fs from 'node:fs';
30
+ import path from 'node:path';
31
+ import { fileURLToPath } from 'node:url';
32
+ import { createRequire } from 'node:module';
33
+
34
+ const require = createRequire(import.meta.url);
35
+ const inbox = require('../modules/ideas/inbox.js');
36
+
37
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
38
+
39
+ // The migration that currently defines the constraint. If a later migration
40
+ // widens it again, point this at that file — forcing that edit is the job.
41
+ const CONSTRAINT_MIGRATION = path.join(
42
+ ROOT, 'migrations', 'core_244_skill_friction_kind.sql'
43
+ );
44
+
45
+ let passed = 0;
46
+ let failed = 0;
47
+ function test(name, fn) {
48
+ try {
49
+ fn();
50
+ passed++;
51
+ console.log(` ok ${name}`);
52
+ } catch (err) {
53
+ failed++;
54
+ console.log(` FAIL ${name}`);
55
+ console.log(` ${err.message}`);
56
+ }
57
+ }
58
+
59
+ // ---- the kind exists at all ------------------------------------------------
60
+
61
+ test('skill-friction is a valid kind, so the two skills that file one stop failing BAD_KIND', () => {
62
+ assert.ok(
63
+ inbox.VALID_KINDS.includes('skill-friction'),
64
+ "/planning-session and /priority-session both instruct kind='skill-friction'"
65
+ );
66
+ assert.equal(inbox.SKILL_FRICTION_KIND, 'skill-friction');
67
+ });
68
+
69
+ test('widening the enum did not drop anything that was already in it', () => {
70
+ for (const k of [
71
+ 'feature', 'bug', 'infra', 'refactor', 'spike', 'learning-capture',
72
+ 'cleanup', 'decision', 'blocker-resolution', 'unclassified', 'criterion-proposal',
73
+ ]) {
74
+ assert.ok(inbox.VALID_KINDS.includes(k), `${k} missing from VALID_KINDS`);
75
+ }
76
+ });
77
+
78
+ // ---- JS and SQL say the same thing -----------------------------------------
79
+
80
+ test('the SQL CHECK constraint and VALID_KINDS list exactly the same kinds', () => {
81
+ const sql = fs.readFileSync(CONSTRAINT_MIGRATION, 'utf8');
82
+ const m = /CHECK\s*\(\s*kind\s+IN\s*\(([\s\S]*?)\)\s*\)/i.exec(sql);
83
+ assert.ok(m, 'the migration declares a CHECK (kind IN (...)) constraint');
84
+
85
+ const fromSql = [...m[1].matchAll(/'([^']+)'/g)].map((x) => x[1]);
86
+ assert.ok(fromSql.length > 0, 'the constraint lists at least one kind');
87
+
88
+ const a = [...fromSql].sort();
89
+ const b = [...inbox.VALID_KINDS].sort();
90
+ assert.deepEqual(
91
+ a, b,
92
+ `SQL and JS disagree — only in SQL: [${a.filter((k) => !b.includes(k))}], ` +
93
+ `only in JS: [${b.filter((k) => !a.includes(k))}]`
94
+ );
95
+ });
96
+
97
+ // ---- the visibility gate ---------------------------------------------------
98
+
99
+ test('a viewer who may see everything costs no clause and no bind parameters', () => {
100
+ for (const rank of [null, undefined, 'metic', 'archon', 'Archon', 'something-new']) {
101
+ const params = [];
102
+ assert.equal(inbox.skillFrictionHiddenClause(rank, params), null, `rank=${rank}`);
103
+ assert.equal(params.length, 0, `rank=${rank} pushed no params`);
104
+ }
105
+ });
106
+
107
+ test('a Xenos is hidden from BOTH markers — the kind and the legacy title prefix', () => {
108
+ const params = [];
109
+ const clause = inbox.skillFrictionHiddenClause('xenos', params);
110
+ assert.ok(clause, 'a Xenos gets a clause');
111
+ assert.match(clause, /kind <> \$1/, 'the kind marker is tested');
112
+ assert.match(clause, /title NOT LIKE \$2/, 'the legacy prefix marker is tested too');
113
+ assert.deepEqual(params, [inbox.SKILL_FRICTION_KIND, `${inbox.SKILL_FRICTION_TAG}%`]);
114
+ });
115
+
116
+ test('the rank test is case-insensitive, so a capitalised Xenos is still hidden', () => {
117
+ const params = [];
118
+ assert.ok(inbox.skillFrictionHiddenClause('XENOS', params), 'XENOS is still a Xenos');
119
+ assert.equal(inbox.rankCanSeeSkillFriction('Xenos'), false);
120
+ assert.equal(inbox.rankCanSeeSkillFriction('metic'), true);
121
+ assert.equal(inbox.rankCanSeeSkillFriction(null), true);
122
+ });
123
+
124
+ test('bind parameters are numbered from where the caller already was, not from 1', () => {
125
+ const params = ['open', 42];
126
+ const clause = inbox.skillFrictionHiddenClause('xenos', params, 'i');
127
+ assert.match(clause, /i\.kind <> \$3/, 'continues the caller\'s numbering');
128
+ assert.match(clause, /i\.title NOT LIKE \$4/);
129
+ assert.equal(params.length, 4, 'and appended exactly two');
130
+ });
131
+
132
+ test('the table alias is applied to both columns or to neither', () => {
133
+ const withAlias = inbox.skillFrictionHiddenClause('xenos', [], 'i');
134
+ assert.match(withAlias, /i\.kind/);
135
+ assert.match(withAlias, /i\.title/);
136
+ const bare = inbox.skillFrictionHiddenClause('xenos', []);
137
+ assert.doesNotMatch(bare, /\bi\./, 'no alias means no alias on either column');
138
+ assert.match(bare, /\bkind <>/);
139
+ assert.match(bare, /\btitle NOT LIKE/);
140
+ });
141
+
142
+ test('the clause is parenthesised, so ANDing it into a WHERE cannot change the other terms', () => {
143
+ const clause = inbox.skillFrictionHiddenClause('xenos', []);
144
+ assert.ok(clause.startsWith('(') && clause.endsWith(')'), `not parenthesised: ${clause}`);
145
+ assert.match(clause, / AND /, 'both markers must hold for the row to be shown');
146
+ });
147
+
148
+ // ---- the legacy marker is not quietly retired ------------------------------
149
+
150
+ test('the legacy title prefix is still exported and still the nightly routine\'s marker', () => {
151
+ assert.equal(inbox.SKILL_FRICTION_TAG, '[skill-friction]');
152
+ const routine = fs.readFileSync(
153
+ path.join(ROOT, '.claude', 'scheduled-tasks', 'skill-usage-review', 'SKILL.md'), 'utf8'
154
+ );
155
+ assert.ok(
156
+ routine.includes('[skill-friction]'),
157
+ 'skill-usage-review still tags the prefix — the gate must keep recognising it'
158
+ );
159
+ });
160
+
161
+ console.log('');
162
+ console.log(`skill-friction kind + visibility gate (task 1003105): ${passed} passed, ${failed} failed`);
163
+ if (failed > 0) process.exit(1);