@bongos/core 1.19.288 → 1.19.290

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.
@@ -362,6 +362,9 @@ const MODULE_EXPECTED_RANKS = {
362
362
  // Pinning catches an accidental requireRank re-gate that would lock builders
363
363
  // out of asking/accepting, and any gate removal.
364
364
  'GET /goals/inbox': 'any-builder',
365
+ // task 1003062 (R07): a pre-creation READ of open goal titles — same surface
366
+ // and same rank as GET /goals. Creates nothing.
367
+ 'GET /goals/suggestions': 'any-builder',
365
368
  'PATCH /goals/:id/visibility': 'any-builder',
366
369
  // task 1003102: a goal's own authority (owner|manager|Archon, in-handler
367
370
  // authorizeGoalManagement) corrects its own title/description — same shape
@@ -506,8 +509,20 @@ function classifyGate(lines, i, { floors } = {}) {
506
509
  // (not any `=>`) avoids cutting at an inline-arrow middleware like
507
510
  // `rateLimit({ key: (req) => req.ip })`. If there's no `=> {` in the window
508
511
  // (e.g. a `function` handler), fall back to the full window — prior behaviour.
509
- const handlerAt = window.search(/=>\s*\{/);
510
- const tail = handlerAt === -1 ? window : window.slice(0, handlerAt);
512
+ // task 1003062: FIRST clamp the window to THIS route's own declaration. The
513
+ // `=> {` truncation below only fires for an inline-arrow handler; a route whose
514
+ // handler is a FACTORY CALL — `router.get(p, auth.requireBuilder, mk({...}))` —
515
+ // contains no `=> {`, so the old code fell back to the full 20 lines and
516
+ // absorbed the NEXT route's requirePermission, recording an any-builder read as
517
+ // metic+archon. Wrong in the tightening direction, but still wrong: the pin and
518
+ // the audit then describe a gate that is not there. Factory handlers became
519
+ // common because the fitness oversize ratchet pushes handler bodies out of large
520
+ // route files, so this is not an exotic shape. A window must never cross into a
521
+ // sibling declaration.
522
+ const nextRouteAt = window.search(/\n\s*router\.(get|post|patch|put|delete|use)\s*\(/);
523
+ const own = nextRouteAt === -1 ? window : window.slice(0, nextRouteAt);
524
+ const handlerAt = own.search(/=>\s*\{/);
525
+ const tail = handlerAt === -1 ? own : own.slice(0, handlerAt);
511
526
 
512
527
  const perms = permissionKeysIn(tail);
513
528
  const byRank = rankFromRankGate(tail);
package/src/module-api.js CHANGED
@@ -1003,8 +1003,12 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
1003
1003
  // landed since 1.19.286 with no explicit bump. run 32539649191. (task 1002620)
1004
1004
  // 1.19.288 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1005
1005
  // landed since 1.19.287 with no explicit bump. run 32546561232. (task 1002620)
1006
+ // 1.19.289 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1007
+ // landed since 1.19.288 with no explicit bump. run 32549912116. (task 1002620)
1008
+ // 1.19.290 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1009
+ // landed since 1.19.289 with no explicit bump. run 32552538814. (task 1002620)
1006
1010
  // ---------------------------------------------------------------------------
1007
- const CORE_VERSION = '1.19.288'; // CI auto-patch carrier (ADR 0161); see changelog above
1011
+ const CORE_VERSION = '1.19.290'; // CI auto-patch carrier (ADR 0161); see changelog above
1008
1012
 
1009
1013
  // A namespaced logger so a module's log lines are attributable + consistent.
1010
1014
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -0,0 +1,195 @@
1
+ // tests/goal_suggest.mjs — the pre-creation goal suggester (task 1003062 / R07 of
2
+ // goal 1000071).
3
+ //
4
+ // "Which goal should this work live in?", answered BEFORE anything is created.
5
+ // buildGoalAdvisory already answered it, but only AFTER a task existed; R08 (hall
6
+ // picker), R09 (CLI) and R11 (Discord) all have a draft title and no row yet.
7
+ //
8
+ // What these pin:
9
+ // 1. It CREATES NOTHING. That is the entire point of a pre-creation read, and the
10
+ // easiest thing for a later refactor to break by routing it through a writer.
11
+ // 2. RANKING, not "the first five open goals" — with the honest limits of token
12
+ // overlap written down rather than papered over (see the false-positive test).
13
+ // 3. The catch-all is never suggested (it is the fallback when there is no home —
14
+ // a different question, owned by R03).
15
+ // 4. No confident match returns [] rather than noise, and the route still tells
16
+ // the caller how many goals exist so it can offer "browse all" honestly.
17
+ //
18
+ // Run: node tests/goal_suggest.mjs
19
+
20
+ import { strict as assert } from 'node:assert';
21
+ import { createRequire } from 'node:module';
22
+ import { readFileSync } from 'node:fs';
23
+ import { makeRunner } from './helpers.mjs';
24
+
25
+ process.env.NODE_ENV = 'test';
26
+ const require = createRequire(import.meta.url);
27
+ const { suggestGoals, goalSuggestHandler, SUGGEST_LIMIT } = require('../modules/lifecycle/goal-suggest.js');
28
+ const { test, summary } = makeRunner();
29
+
30
+ const V = 'BONGOS-V1';
31
+ const GOALS = [
32
+ { id: 1000024, title: 'Build-pipeline integrity', scope_modules: ['lifecycle', 'grading'] },
33
+ { id: 1000025, title: 'Builder experience — hall, dev box, and skills', scope_modules: ['hall-ui', 'dev-box'] },
34
+ { id: 1000071, title: 'Idea routing — work that has a home lands in it', scope_modules: ['ideas', 'discord'] },
35
+ { id: 1000003, title: `${V} — general`, scope_modules: [] },
36
+ ];
37
+
38
+ // ---- 2. it ranks, and the ranking is explainable --------------------------
39
+
40
+ await test('RANKS: a draft about the ship pipeline puts Build-pipeline integrity first', () => {
41
+ const r = suggestGoals(GOALS, { title: 'ship pipeline grading is flaky' }, { versionId: V });
42
+ assert.equal(r[0].id, 1000024);
43
+ assert.ok(r[0].score >= 4, `two title terms should score >=4, got ${r[0].score}`);
44
+ assert.deepEqual(r[0].matched_terms, ['grading', 'pipeline'], 'and it SAYS why it matched');
45
+ });
46
+
47
+ await test('RANKS: a module key is a matchable signal, not just the title', () => {
48
+ // 'discord' appears only in scope_modules for goal 1000071.
49
+ const r = suggestGoals(GOALS, { title: 'discord filing loses the author' }, { versionId: V });
50
+ assert.equal(r[0].id, 1000071);
51
+ assert.ok(r[0].matched_terms.includes('discord'));
52
+ });
53
+
54
+ await test('RANKS: title terms outweigh body terms (2 vs 1), same rule as suggestCriteria', () => {
55
+ const inTitle = suggestGoals(GOALS, { title: 'grading' }, { versionId: V })[0];
56
+ const inBody = suggestGoals(GOALS, { title: 'unrelated', body: 'grading' }, { versionId: V })[0];
57
+ assert.equal(inTitle.score, 2);
58
+ assert.equal(inBody.score, 1);
59
+ });
60
+
61
+ await test('RANKS: a term in BOTH title and body counts once, at the title weight', () => {
62
+ const r = suggestGoals(GOALS, { title: 'grading', body: 'grading grading' }, { versionId: V })[0];
63
+ assert.equal(r.score, 2, 'the stronger signal wins; it does not accumulate');
64
+ });
65
+
66
+ await test('RANKS: ties break by ascending id so a picker renders stably', () => {
67
+ const goals = [
68
+ { id: 900, title: 'routing', scope_modules: [] },
69
+ { id: 100, title: 'routing', scope_modules: [] },
70
+ { id: 500, title: 'routing', scope_modules: [] },
71
+ ];
72
+ assert.deepEqual(suggestGoals(goals, { title: 'routing' }, { versionId: V }).map((g) => g.id), [100, 500, 900]);
73
+ });
74
+
75
+ // ---- the honest limit, written down --------------------------------------
76
+
77
+ await test('LIMIT (known + accepted): token overlap can match on an incidental word', () => {
78
+ // Real example found while building this: "the goals page search BOX is broken"
79
+ // scores the DEV BOX goal, because both contain the token 'box'. Bag-of-words
80
+ // matching cannot tell "search box" from "dev box".
81
+ //
82
+ // This is NOT hidden — it is why every suggestion carries `score` and
83
+ // `matched_terms`: a caller can show WHY it matched and a human decides. A
84
+ // suggester that silently guessed, with no visible reason, would be worse.
85
+ // Fixing it properly means real relevance scoring (embeddings/FTS), which is a
86
+ // separate decision, not something to smuggle into R07.
87
+ const r = suggestGoals(GOALS, { title: 'the goals page search box is broken' }, { versionId: V });
88
+ assert.equal(r[0].id, 1000025, 'the incidental match is what happens today');
89
+ assert.deepEqual(r[0].matched_terms, ['box'], 'and the single weak term is VISIBLE to the caller');
90
+ assert.equal(r[0].score, 2, 'one title term — a low score, which is the signal it is weak');
91
+ });
92
+
93
+ // ---- 3. the catch-all is never a suggestion ------------------------------
94
+
95
+ await test('CATCH-ALL: the "<version> — general" goal is never suggested, even on an exact hit', () => {
96
+ const r = suggestGoals(GOALS, { title: `${V} general` }, { versionId: V });
97
+ assert.ok(!r.some((g) => g.id === 1000003), 'the catch-all is the no-home FALLBACK (R03), not a suggestion');
98
+ });
99
+
100
+ await test('CATCH-ALL: without a versionId it cannot be identified, so nothing is dropped', () => {
101
+ // candidateGoals keys the catch-all off the version name; no version => no filter.
102
+ const r = suggestGoals(GOALS, { title: 'general' }, {});
103
+ assert.ok(r.some((g) => g.id === 1000003), 'documented consequence of omitting versionId');
104
+ });
105
+
106
+ // ---- 4. no confident match, and bounds ----------------------------------
107
+
108
+ await test('NO MATCH: a draft matching nothing returns [] rather than arbitrary goals', () => {
109
+ assert.deepEqual(suggestGoals(GOALS, { title: 'xyzzy plugh frobnicate' }, { versionId: V }), []);
110
+ });
111
+
112
+ await test('NO MATCH: includeZero:true is available for a caller that wants the full list', () => {
113
+ const r = suggestGoals(GOALS, { title: 'xyzzy' }, { versionId: V, includeZero: true });
114
+ assert.equal(r.length, 3, 'all three non-catch-all goals, all scoring 0');
115
+ assert.ok(r.every((g) => g.score === 0));
116
+ });
117
+
118
+ await test('BOUNDS: limit defaults to SUGGEST_LIMIT and is respected; degenerate input is safe', () => {
119
+ assert.equal(SUGGEST_LIMIT, 5);
120
+ assert.equal(suggestGoals(GOALS, { title: 'routing' }, { versionId: V, limit: 1 }).length, 1);
121
+ assert.deepEqual(suggestGoals(GOALS, { title: 'routing' }, { versionId: V, limit: 0 }), []);
122
+ assert.deepEqual(suggestGoals(null, { title: 'x' }, {}), []);
123
+ assert.deepEqual(suggestGoals(GOALS, {}, { versionId: V }), [], 'no draft text => no suggestions');
124
+ });
125
+
126
+ // ---- 1. IT CREATES NOTHING ----------------------------------------------
127
+
128
+ function fakeRes() {
129
+ return {
130
+ code: null, payload: null, statusCode: 200,
131
+ json(p) { this.payload = p; return this; },
132
+ status(c) { this.statusCode = c; return this; },
133
+ fail(code, s, extra) { this.code = code; this.statusCode = typeof s === 'number' ? s : (s && s.status) || 500; this.payload = extra || s; return this; },
134
+ };
135
+ }
136
+
137
+ await test('CREATES NOTHING: the handler only READS goals — no writer is reachable from it', async () => {
138
+ const calls = [];
139
+ const db = { listGoals: async (a) => { calls.push(['listGoals', a]); return GOALS; } };
140
+ const handler = goalSuggestHandler({ db, LIMITS: { TITLE: 200 } });
141
+ const res = fakeRes();
142
+ await handler({ query: { version: V, title: 'ship pipeline grading' } }, res);
143
+ assert.equal(res.code, null, `expected success, got ${res.code}`);
144
+ assert.equal(res.payload.suggestions[0].id, 1000024);
145
+ assert.deepEqual(calls.map((c) => c[0]), ['listGoals'], 'exactly one read, and it is a read');
146
+ assert.equal(res.payload.created, null, 'the response says so explicitly');
147
+ // Source contract: nothing in this module may call a create/insert/update path.
148
+ const src = readFileSync(new URL('../modules/lifecycle/goal-suggest.js', import.meta.url), 'utf8');
149
+ assert.ok(!/\b(createTask|createGoal|insert|INSERT|UPDATE|captureIdea)\b/.test(src),
150
+ 'a pre-creation suggester must contain no write path at all');
151
+ });
152
+
153
+ await test('the handler refuses without a version, and without any draft text', async () => {
154
+ const handler = goalSuggestHandler({ db: { listGoals: async () => GOALS }, LIMITS: { TITLE: 200 } });
155
+ let res = fakeRes();
156
+ await handler({ query: { title: 'x' } }, res);
157
+ assert.equal(res.code, 'version_required');
158
+ res = fakeRes();
159
+ await handler({ query: { version: V } }, res);
160
+ assert.equal(res.code, 'draft_text_required', 'ranking against nothing is not a suggestion');
161
+ });
162
+
163
+ await test('the handler reports open_goal_count so an empty result is not mistaken for an empty version', async () => {
164
+ const handler = goalSuggestHandler({ db: { listGoals: async () => GOALS }, LIMITS: { TITLE: 200 } });
165
+ const res = fakeRes();
166
+ await handler({ query: { version: V, title: 'xyzzy plugh' } }, res);
167
+ assert.deepEqual(res.payload.suggestions, []);
168
+ assert.equal(res.payload.open_goal_count, 4, 'four goals exist; none matched — different facts');
169
+ });
170
+
171
+ await test('kind is ECHOED but never ranked on (it decides landing, not which goal)', async () => {
172
+ const handler = goalSuggestHandler({ db: { listGoals: async () => GOALS }, LIMITS: { TITLE: 200 } });
173
+ const withKind = fakeRes();
174
+ await handler({ query: { version: V, title: 'grading', kind: 'bug' } }, withKind);
175
+ const noKind = fakeRes();
176
+ await handler({ query: { version: V, title: 'grading' } }, noKind);
177
+ assert.equal(withKind.payload.kind, 'bug', 'echoed');
178
+ assert.equal(noKind.payload.kind, null);
179
+ assert.deepEqual(
180
+ withKind.payload.suggestions.map((s) => [s.id, s.score]),
181
+ noKind.payload.suggestions.map((s) => [s.id, s.score]),
182
+ 'kind must not change the ranking — the R05 landing matrix owns kind',
183
+ );
184
+ });
185
+
186
+ await test('the handler clamps limit into a sane range', async () => {
187
+ const handler = goalSuggestHandler({ db: { listGoals: async () => GOALS }, LIMITS: { TITLE: 200 } });
188
+ for (const [q, expectMax] of [[{ limit: '999' }, 20], [{ limit: '0' }, 1], [{ limit: 'abc' }, SUGGEST_LIMIT]]) {
189
+ const res = fakeRes();
190
+ await handler({ query: { version: V, title: 'routing grading hall', ...q } }, res);
191
+ assert.ok(res.payload.suggestions.length <= expectMax, `limit ${JSON.stringify(q)} must clamp`);
192
+ }
193
+ });
194
+
195
+ summary();
@@ -0,0 +1,316 @@
1
+ // tests/promote_goal_id.mjs — a triaged promotion lands IN A GOAL (task 1003071 / R13).
2
+ //
3
+ // Before this, both promote paths — the hall triage overlay and the /idea-triage
4
+ // CLI — created a task with no goal_id at all. That does NOT mean the task came
5
+ // out goal-less: db.createTask (modules/lifecycle/db.js) defaults a goal-less
6
+ // create into the version's catch-all "<version> — general" goal. That bucket IS
7
+ // the defect. It carries no done-when criteria, so since ADR 0183 made criteria
8
+ // close themselves off LINKED tasks, work parked there can never advance
9
+ // anything — and it does not read as real goal work on the board. Falling into it
10
+ // had to stop being the silent default.
11
+ //
12
+ // What these pin:
13
+ // 1. PRECEDENCE. The stored `suggested_goal_id` hint beats the R07 suggester.
14
+ // inbox.captureIdea writes that hint even when the matrix declined to route,
15
+ // specifically so triage "inherits the guess instead of re-deriving it"
16
+ // (core_222's own comment). A heuristic must never overrule a named goal.
17
+ // 2. OPT-OUT IS EXPLICIT. Taking the general bucket is still allowed — it just
18
+ // has to be chosen, not defaulted into.
19
+ // 3. DEGRADES, NEVER BLOCKS. A suggester outage must not stop a triage verdict.
20
+ // 4. BOTH PATHS send goal_id. Two surfaces, one contract — the same class of
21
+ // miss as the two claim-insert sites (task 1003137).
22
+ //
23
+ // Run: node tests/promote_goal_id.mjs
24
+
25
+ import { strict as assert } from 'node:assert';
26
+ import { createRequire } from 'node:module';
27
+ import { readFileSync } from 'node:fs';
28
+ import { makeRunner } from './helpers.mjs';
29
+
30
+ process.env.NODE_ENV = 'test';
31
+ const require = createRequire(import.meta.url);
32
+ const { test, summary } = makeRunner();
33
+
34
+ // triage.js captures `cliClient` by destructuring at require time, so a stub has
35
+ // to be in place BEFORE the module loads. Dropping both from the require cache
36
+ // and re-requiring is what makes that possible per-test.
37
+ function freshTriage() {
38
+ delete require.cache[require.resolve('../scripts/gds/triage.js')];
39
+ return require('../scripts/gds/triage.js');
40
+ }
41
+
42
+ const TRIAGE = readFileSync(new URL('../scripts/gds/triage.js', import.meta.url), 'utf8');
43
+ const HALL = readFileSync(new URL('../modules/hall-ui/public/task-detail.js', import.meta.url), 'utf8');
44
+ const SKILL = readFileSync(new URL('../.claude/skills/idea-triage/SKILL.md', import.meta.url), 'utf8');
45
+
46
+ // ==========================================================================
47
+ // BEHAVIOURAL — these INVOKE the code. The static assertions further down pin
48
+ // the wiring a unit test cannot reach (the DOM strings, the skill doc); they do
49
+ // not stand in for actually running it.
50
+ // ==========================================================================
51
+
52
+ const triage = require('../scripts/gds/triage.js');
53
+
54
+ // A stub of the generated typed client: records the call, answers from a script.
55
+ function stubClient(answer) {
56
+ const calls = [];
57
+ const client = async () => ({
58
+ goals: {
59
+ getGoalsSuggestions: async (req) => {
60
+ calls.push(req);
61
+ if (answer instanceof Error) throw answer;
62
+ return answer;
63
+ },
64
+ },
65
+ });
66
+ return { client, calls };
67
+ }
68
+
69
+ const SUGGESTED = {
70
+ ok: true,
71
+ data: { suggestions: [{ id: 1000071, title: 'Idea routing', score: 7, matched_terms: ['triage', 'goal'] }] },
72
+ };
73
+
74
+ await test('BEHAVIOUR: a junk stored hint is ignored, not turned into goalId NaN', async () => {
75
+ for (const junk of ['not-an-id', '', '0', '-3', '4.5', {}]) {
76
+ const { client, calls } = stubClient(SUGGESTED);
77
+ const r = await triage.resolveGoalDefault({ suggested_goal_id: junk, title: 't' }, 'BV1', { client });
78
+ assert.notEqual(r.source, 'hint', `${JSON.stringify(junk)} is not a usable hint`);
79
+ assert.equal(calls.length, 1, 'and it falls through to the suggester rather than dead-ending');
80
+ }
81
+ });
82
+
83
+ await test('BEHAVIOUR: a stored hint wins and the suggester is never even called', async () => {
84
+ const { client, calls } = stubClient(SUGGESTED);
85
+ const r = await triage.resolveGoalDefault({ suggested_goal_id: 1000058, title: 'x' }, 'BV1', { client });
86
+ assert.equal(r.goalId, 1000058);
87
+ assert.equal(r.source, 'hint');
88
+ assert.equal(calls.length, 0, 'a named goal must not be re-derived — that is the whole point of the column');
89
+ });
90
+
91
+ await test('BEHAVIOUR: with no hint, the top suggestion becomes the default, labelled with its score', async () => {
92
+ const { client, calls } = stubClient(SUGGESTED);
93
+ const r = await triage.resolveGoalDefault({ title: 'goal triage routing', body_md: 'b' }, 'BV1', { client });
94
+ assert.equal(r.goalId, 1000071);
95
+ assert.equal(r.source, 'suggester');
96
+ assert.match(r.label, /score 7/);
97
+ assert.match(r.label, /triage\/goal/, 'the matched terms are what let the operator judge it');
98
+ assert.equal(calls.length, 1);
99
+ assert.equal(calls[0].query.version, 'BV1', 'goals are per-version — the wrong version offers an invalid home');
100
+ assert.equal(calls[0].query.limit, 3);
101
+ });
102
+
103
+ await test('BEHAVIOUR: the body is capped before it goes on a query string', async () => {
104
+ const { client, calls } = stubClient(SUGGESTED);
105
+ await triage.resolveGoalDefault({ title: 't', body_md: 'x'.repeat(9000) }, 'BV1', { client });
106
+ assert.equal(calls[0].query.body.length, 2000, 'an un-capped body would blow the URL length');
107
+ });
108
+
109
+ await test('BEHAVIOUR: a suggester THROW degrades to no default, it does not propagate', async () => {
110
+ const { client } = stubClient(new Error('ECONNREFUSED'));
111
+ const r = await triage.resolveGoalDefault({ title: 't' }, 'BV1', { client });
112
+ assert.deepEqual(r, { goalId: null, label: null, source: 'unavailable' },
113
+ 'a triage verdict must survive the suggester being down');
114
+ });
115
+
116
+ await test('BEHAVIOUR: a non-ok or empty response is "none", distinct from "unavailable"', async () => {
117
+ const empty = await triage.resolveGoalDefault({ title: 't' }, 'BV1',
118
+ { client: stubClient({ ok: true, data: { suggestions: [] } }).client });
119
+ assert.equal(empty.source, 'none');
120
+ const notOk = await triage.resolveGoalDefault({ title: 't' }, 'BV1',
121
+ { client: stubClient({ ok: false, status: 500, data: null }).client });
122
+ assert.equal(notOk.source, 'none', 'the two are reported differently because the operator advice differs');
123
+ });
124
+
125
+ await test('BEHAVIOUR: blank ACCEPTS the default; only "-"/"none" declines', () => {
126
+ assert.deepEqual(triage.applyGoalAnswer(1000071, ''), { goalId: 1000071, warn: null });
127
+ assert.deepEqual(triage.applyGoalAnswer(1000071, ' '), { goalId: 1000071, warn: null },
128
+ 'whitespace is a bare Enter, not a decision');
129
+ assert.deepEqual(triage.applyGoalAnswer(1000071, '-'), { goalId: null, warn: null });
130
+ assert.deepEqual(triage.applyGoalAnswer(1000071, 'none'), { goalId: null, warn: null });
131
+ assert.deepEqual(triage.applyGoalAnswer(1000071, 'NONE'), { goalId: null, warn: null });
132
+ assert.deepEqual(triage.applyGoalAnswer(1000071, '1000058'), { goalId: 1000058, warn: null },
133
+ 'an explicit id overrides the default');
134
+ });
135
+
136
+ await test('BEHAVIOUR: junk clears the goal and SAYS SO rather than landing on a wrong one', () => {
137
+ for (const junk of ['abc', '12.5', '-4', '0', 'goal 7']) {
138
+ const r = triage.applyGoalAnswer(1000071, junk);
139
+ assert.equal(r.goalId, null, `"${junk}" must not resolve to a goal`);
140
+ assert.match(r.warn, /general bucket/, `"${junk}" must say where it will land instead`);
141
+ }
142
+ // Number('') is 0 and Number(' ') is 0 — both would silently become goal 0
143
+ // without the blank check above running first. Guarding the pair together.
144
+ assert.equal(triage.applyGoalAnswer(9, '').goalId, 9);
145
+ });
146
+
147
+ await test('BEHAVIOUR: createTask puts goal_id on the body, and omits it entirely when null', async () => {
148
+ const seen = [];
149
+ const fakeClient = {
150
+ tasks: { postTasks: async (req) => { seen.push(req.body); return { ok: true, data: { task: { id: 7 } } }; } },
151
+ };
152
+ const cliLib = require('../scripts/gds/cli-lib.js');
153
+ const realCliClient = cliLib.cliClient;
154
+ cliLib.cliClient = async () => fakeClient;
155
+ try {
156
+ // triage.js destructured cliClient at require time, so patching the module
157
+ // object cannot reach it — re-require in a fresh registry instead.
158
+ const fresh = freshTriage();
159
+ await fresh.createTask({ versionId: 'BV1', title: 't', description: 'd', priority: 3, sourceRef: 'idea:1', goalId: 1000071 });
160
+ await fresh.createTask({ versionId: 'BV1', title: 't', description: 'd', priority: 3, sourceRef: 'idea:2' });
161
+ assert.equal(seen[0].goal_id, 1000071, 'the goal must reach the server');
162
+ assert.ok(!('goal_id' in seen[1]),
163
+ 'and opting out must send NO goal_id key — the server\'s catch-all only applies on absent');
164
+ assert.equal(seen[0].source, 'idea_inbox', 'provenance still travels');
165
+ assert.equal(seen[0].status, 'backlog');
166
+ } finally {
167
+ cliLib.cliClient = realCliClient;
168
+ }
169
+ });
170
+
171
+ await test('BEHAVIOUR: a failed create throws with the status, it does not return a phantom task', async () => {
172
+ const cliLib = require('../scripts/gds/cli-lib.js');
173
+ const realCliClient = cliLib.cliClient;
174
+ cliLib.cliClient = async () => ({
175
+ tasks: { postTasks: async () => ({ ok: false, status: 422, data: { error: 'NO_SUCH_GOAL' } }) },
176
+ });
177
+ try {
178
+ const fresh = freshTriage();
179
+ await assert.rejects(
180
+ () => fresh.createTask({ versionId: 'BV1', title: 't', description: 'd', goalId: 999999999 }),
181
+ /422/,
182
+ );
183
+ } finally {
184
+ cliLib.cliClient = realCliClient;
185
+ }
186
+ });
187
+
188
+ // ==========================================================================
189
+ // STATIC — the surfaces a unit test cannot invoke (browser DOM strings, docs).
190
+ // ==========================================================================
191
+
192
+ // ---- 4. both paths send goal_id -------------------------------------------
193
+
194
+ await test('BOTH PATHS: the CLI createTask and the hall payload each send goal_id', () => {
195
+ assert.match(TRIAGE, /if \(goalId != null\) body\.goal_id = Number\(goalId\);/,
196
+ 'CLI: goal_id must reach the create body');
197
+ assert.match(TRIAGE, /async function createTask\(\{[^}]*goalId/,
198
+ 'CLI: createTask must accept it rather than ignore the caller');
199
+ assert.match(HALL, /payload\.goal_id = Number\(form\.goal\.value\)/,
200
+ 'hall: goal_id must reach the create payload');
201
+ assert.match(HALL, /data-goal-select/, 'hall: and there must be a picker to choose it from');
202
+ });
203
+
204
+ await test('BOTH PATHS: the stale "does not accept kind" note is corrected, not left to mislead', () => {
205
+ assert.ok(!/doesn't currently accept 'kind'/.test(TRIAGE),
206
+ 'that claim was false — routes/tasks.js takes kind and the hall path sends it');
207
+ assert.match(TRIAGE, /no longer true/, 'and the correction says so explicitly');
208
+ });
209
+
210
+ // ---- 1. precedence: the stored hint wins ---------------------------------
211
+
212
+ // (The CLI's precedence is proven by the BEHAVIOUR block above — it drives the
213
+ // real function and asserts the suggester is never called on a hint. A source-shape
214
+ // assertion here would only restate that, less reliably.)
215
+
216
+ await test('PRECEDENCE: the hall picker marks the hint selected, and only auto-selects a suggestion without one', () => {
217
+ const fn = HALL.slice(HALL.indexOf('async function goalOptionsFor'), HALL.indexOf('async function renderPromote'));
218
+ assert.match(fn, /idea\.suggested_goal_id/, 'the hint is read');
219
+ assert.match(fn, /carried from the filing/, 'and labelled as inherited, not guessed');
220
+ assert.match(fn, /hint == null && i === 0 \? ' selected' : ''/,
221
+ 'a suggestion is pre-selected ONLY when there is no hint to honour');
222
+ });
223
+
224
+ // ---- the suggester's weak-evidence signal is shown, not hidden -----------
225
+
226
+ await test('the suggester default shows score + matched terms so it can be judged', () => {
227
+ assert.match(TRIAGE, /score \$\{top\.score\}/, 'CLI shows the score');
228
+ assert.match(TRIAGE, /matched_terms/, 'CLI shows why it matched');
229
+ assert.match(HALL, /matched: /, 'hall shows why it matched');
230
+ // task 1003062 documented that token overlap can match an incidental word; the
231
+ // visible score is what stops that being an invisible wrong default.
232
+ assert.match(HALL, /incidental word/, 'and the reason it is shown is written down');
233
+ });
234
+
235
+ // ---- 2. opting out is explicit ------------------------------------------
236
+
237
+ await test('OPT-OUT: the prompt itself offers the way out, and the branch delegates the meaning', () => {
238
+ const branch = TRIAGE.slice(TRIAGE.indexOf('const gd = await resolveGoalDefault'), TRIAGE.indexOf('const task = await createTask'));
239
+ assert.match(branch, /general bucket/, 'the prompt has to say where opting out lands');
240
+ assert.match(branch, /applyGoalAnswer\(gd\.goalId, goalAns\)/,
241
+ 'the branch must delegate to the tested helper, not re-implement the parsing inline');
242
+ assert.match(branch, /picked\.warn/, 'and surface its warning');
243
+ assert.match(branch, /resolveGoalDefault\(\{ \.\.\.idea, title \}, versionId\)/,
244
+ 'the suggester must see the title the operator just confirmed, not the filed one');
245
+ // The semantics themselves are covered by the BEHAVIOUR block above — this
246
+ // only pins that main() is actually wired to them.
247
+ });
248
+
249
+ await test('OPT-OUT: the hall names where opting out actually lands, and only defaults to it last', () => {
250
+ const fn = HALL.slice(HALL.indexOf('async function goalOptionsFor'), HALL.indexOf('async function renderPromote'));
251
+ // "— no goal —" would be a lie: the server files a goal_id-less create under
252
+ // the version's catch-all. The label has to say so.
253
+ assert.match(fn, /general bucket/);
254
+ assert.ok(!/— no goal —/.test(fn), 'the honest label replaced the misleading one');
255
+ assert.match(fn, /opts\.length === 0 \? ' selected' : ''/, 'the opt-out is the fallback, not the default');
256
+ assert.match(fn, /a choice rather than the silent default/, 'and the intent is stated');
257
+ });
258
+
259
+ await test('the CLI result line names the goal, or says the task fell into the general bucket', () => {
260
+ assert.match(TRIAGE, /goal \$\{goalId\}` : ', → general bucket'/,
261
+ 'where it landed must be visible in the output, both ways');
262
+ });
263
+
264
+ // ---- 3. degrades, never blocks -----------------------------------------
265
+
266
+ await test('DEGRADES: a suggester outage yields no default rather than throwing', () => {
267
+ const fn = TRIAGE.slice(TRIAGE.indexOf('async function resolveGoalDefault'), TRIAGE.indexOf('async function createTask'));
268
+ assert.match(fn, /catch \(_\) \{\s*return \{ goalId: null, label: null, source: 'unavailable' \}/,
269
+ 'the CLI must swallow it and report unavailable');
270
+ assert.match(TRIAGE, /goal suggester unreachable/, 'and tell the operator what happened');
271
+ const hallFn = HALL.slice(HALL.indexOf('async function goalOptionsFor'), HALL.indexOf('async function renderPromote'));
272
+ assert.match(hallFn, /catch \(_\) \{ suggestions = \[\]; \}/, 'the hall must degrade to no suggestions');
273
+ assert.match(hallFn, /must not stop a triage verdict/, 'the reasoning is recorded');
274
+ });
275
+
276
+ await test('the hall guards the stored hint against a junk value reaching the DOM', () => {
277
+ const fn = HALL.slice(HALL.indexOf('async function goalOptionsFor'), HALL.indexOf('async function renderPromote'));
278
+ assert.match(fn, /Number\.isFinite\(raw\) && raw > 0 \? raw : null/,
279
+ 'value="NaN" would be POSTed as a goal id');
280
+ });
281
+
282
+ await test('the hall degrades a DEAD SESSION to no suggestions instead of navigating away', () => {
283
+ const fn = HALL.slice(HALL.indexOf('async function goalOptionsFor'), HALL.indexOf('async function renderPromote'));
284
+ // getJSON without softAuth calls location.assign on a 401 — from a background
285
+ // fetch that would discard whatever the builder had typed into the form.
286
+ assert.match(fn, /softAuth: true/);
287
+ });
288
+
289
+ await test('DEGRADES: the hall re-fetches goals when the version changes, newest-wins', () => {
290
+ // Two quick version changes race; an out-of-order resolve would paint another
291
+ // version's goals, all of which the server would reject.
292
+ assert.match(HALL, /var mine = \+\+goalFetch;/);
293
+ assert.match(HALL, /if \(mine !== goalFetch\) return;/);
294
+ // Goals belong to a version; keeping stale options would offer a home the
295
+ // server would reject.
296
+ assert.match(HALL, /vsel\.addEventListener\('change'/);
297
+ assert.match(HALL, /await goalOptionsFor\(vsel\.value\)/);
298
+ });
299
+
300
+ await test('the CLI uses the GENERATED typed client method, not the raw escape hatch', () => {
301
+ assert.match(TRIAGE, /api\.goals\.getGoalsSuggestions\(/,
302
+ 'so a path or param rename reds the client build instead of failing at runtime');
303
+ });
304
+
305
+ // ---- the skill contract ------------------------------------------------
306
+
307
+ await test('SKILL DOC: the inbox is declared homeless-only, with both consequences spelled out', () => {
308
+ assert.match(SKILL, /HOMELESS-ONLY/, 'the contract is stated');
309
+ assert.match(SKILL, /Every promote names a goal/, 'consequence 1');
310
+ assert.match(SKILL, /general` goal/, 'and it is honest about what opting out means');
311
+ assert.match(SKILL, /inherited, not guessed/, 'consequence 2 — precedence');
312
+ assert.match(SKILL, /0183/, 'and why a goal-less task now matters more than it used to');
313
+ assert.match(SKILL, /HOMELESS/i, 'the description line carries it too');
314
+ });
315
+
316
+ summary();