@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
|
@@ -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.
|
|
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\.
|
|
128
|
-
assert.match(js, /class="ov-row__title">\$\{escapeHtml\(
|
|
129
|
-
assert.match(js, /class="ov-row__sub">\$\{escapeHtml\(
|
|
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);
|