@bongos/core 1.19.495 → 1.19.497
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 +45 -25
- package/.claude/skills/planning-session/SKILL.md +3 -1
- package/docs/copy-inventory.md +39 -36
- package/docs/copy-registry.json +69 -42
- package/docs/file-map.md +1 -1
- package/docs/module-api-changelog.md +4 -0
- package/modules/discord/discord-inbound.js +73 -1
- package/modules/hall-ui/public/task.js +87 -3
- package/modules/ideas/inbox.js +8 -1
- package/modules/lifecycle/goal-advisory.js +9 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/capture.js +151 -2
- package/scripts/gds/seed-bongos-v1-idea-routing-tasks.js +25 -1
- package/scripts/gds/seed-verify-criteria.js +69 -0
- package/src/module-api.js +1 -1
- package/tests/capture_goal_routing.mjs +242 -0
- package/tests/discord_inbound.mjs +89 -1
- package/tests/goal_advisory.mjs +28 -0
- package/tests/hall_record_world.mjs +71 -3
- package/tests/idea_routing.mjs +33 -0
- package/tests/planning_session_skill.mjs +57 -0
- package/tests/seed_verify_criteria.mjs +149 -0
|
@@ -46,10 +46,27 @@ await t('deriveIdea: long first line is capped (≤200, ellipsis)', () => {
|
|
|
46
46
|
|
|
47
47
|
// ---- recordInboundIdea (fakes) --------------------------------------------
|
|
48
48
|
function makeDeps(over = {}) {
|
|
49
|
-
const calls = { capture: [], audit: [], persist: [] };
|
|
49
|
+
const calls = { capture: [], audit: [], persist: [], suggest: [], listVersions: 0 };
|
|
50
50
|
const deps = {
|
|
51
51
|
getBuilderByDiscordUserId: async () => (over.builder === undefined ? null : over.builder),
|
|
52
52
|
captureIdea: async (a) => { calls.capture.push(a); if (over.captureThrows) throw new Error('db boom'); return { id: over.ideaId ?? 77 }; },
|
|
53
|
+
// task 1003068 (R12): the goal-suggest fake. Default: no confident match
|
|
54
|
+
// (null) — the ordinary case. `over.goal` supplies a match; `over.suggestThrows`
|
|
55
|
+
// simulates the suggester outage this vector must survive.
|
|
56
|
+
suggestGoalForCapture: async (draft) => {
|
|
57
|
+
calls.suggest.push(draft);
|
|
58
|
+
if (over.suggestThrows) throw new Error('suggest boom');
|
|
59
|
+
return over.goal === undefined ? null : over.goal;
|
|
60
|
+
},
|
|
61
|
+
// task 1003068 (R12): the suggested_version DEFAULT fallback — only
|
|
62
|
+
// consulted when no goal matched. Default: no versions at all (empty),
|
|
63
|
+
// so the ordinary fixture keeps the version hint null. `over.versions`
|
|
64
|
+
// supplies rows; `over.listVersionsThrows` simulates the lookup outage.
|
|
65
|
+
listVersions: async () => {
|
|
66
|
+
calls.listVersions += 1;
|
|
67
|
+
if (over.listVersionsThrows) throw new Error('versions boom');
|
|
68
|
+
return over.versions === undefined ? [] : over.versions;
|
|
69
|
+
},
|
|
53
70
|
insertAuditLog: async (a) => { calls.audit.push(a); if (over.auditThrows) throw new Error('audit boom'); return { id: 1 }; },
|
|
54
71
|
persistImages: async (urls, opts) => {
|
|
55
72
|
calls.persist.push({ urls, opts });
|
|
@@ -88,6 +105,77 @@ await t('recordInboundIdea: never produces the rank-gated criterion-proposal kin
|
|
|
88
105
|
assert.notEqual(calls.capture[0].kind, 'criterion-proposal');
|
|
89
106
|
});
|
|
90
107
|
|
|
108
|
+
// ---- goal/version routing HINTS (task 1003068, BV1.R12) -------------------
|
|
109
|
+
await t('recordInboundIdea: a confident suggester match writes the HINT columns, never routes', async () => {
|
|
110
|
+
const { deps, calls } = makeDeps({
|
|
111
|
+
builder: { id: 9, github_login: 'mona' },
|
|
112
|
+
goal: { id: 42, title: 'The vendor rework', version_id: 'BONGOS-V1', score: 6 },
|
|
113
|
+
});
|
|
114
|
+
const r = await ib.recordInboundIdea({ discordUserId: GOOD_ID, content: 'Vendors should restock overnight', messageId: 'm' }, deps);
|
|
115
|
+
assert.equal(r.outcome, 'filed');
|
|
116
|
+
assert.equal(calls.capture[0].suggestedGoalId, 42, 'the suggested goal rides as the hint');
|
|
117
|
+
assert.equal(calls.capture[0].suggestedVersion, 'BONGOS-V1', "the goal's own version is the version hint");
|
|
118
|
+
// THE HARD RULE: goalId is never set, however confident the match — this
|
|
119
|
+
// vector suggests, it never routes (unlike discord-bugs.js). captureIdea
|
|
120
|
+
// would ROUTE (create a task) the moment goalId is non-null, so the absence
|
|
121
|
+
// of the key itself is the assertion, not just its value.
|
|
122
|
+
assert.equal('goalId' in calls.capture[0], false, 'goalId must never be passed — never route, never a task');
|
|
123
|
+
assert.equal(calls.listVersions, 0, "a matched goal's own version wins — the default lookup is never consulted");
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
await t('recordInboundIdea: no confident match, no building version → both hints null, still filed', async () => {
|
|
127
|
+
const { deps, calls } = makeDeps({ builder: { id: 9, github_login: 'mona' }, goal: null });
|
|
128
|
+
const r = await ib.recordInboundIdea({ discordUserId: GOOD_ID, content: 'a vague thought', messageId: 'm' }, deps);
|
|
129
|
+
assert.equal(r.outcome, 'filed');
|
|
130
|
+
assert.equal(calls.capture[0].suggestedGoalId, null);
|
|
131
|
+
assert.equal(calls.capture[0].suggestedVersion, null);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
await t('recordInboundIdea: no confident match → suggested_version DEFAULTS to the most-recently-started building version', async () => {
|
|
135
|
+
const { deps, calls } = makeDeps({
|
|
136
|
+
builder: { id: 9, github_login: 'mona' },
|
|
137
|
+
goal: null,
|
|
138
|
+
versions: [
|
|
139
|
+
{ id: 'GDS-V3', status: 'shipped', started_at: '2026-01-01T00:00:00Z' },
|
|
140
|
+
{ id: 'BONGOS-V1', status: 'building', started_at: '2026-06-01T00:00:00Z' },
|
|
141
|
+
{ id: 'BONGOS-V2', status: 'building', started_at: '2026-08-15T00:00:00Z' },
|
|
142
|
+
],
|
|
143
|
+
});
|
|
144
|
+
const r = await ib.recordInboundIdea({ discordUserId: GOOD_ID, content: 'a vague thought', messageId: 'm' }, deps);
|
|
145
|
+
assert.equal(r.outcome, 'filed');
|
|
146
|
+
assert.equal(calls.capture[0].suggestedGoalId, null, 'still no goal — nothing confident matched');
|
|
147
|
+
assert.equal(calls.capture[0].suggestedVersion, 'BONGOS-V2', 'the LATER-started building version wins, the shipped one is ignored');
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
await t('recordInboundIdea: suggester throws → filing still succeeds, hints null', async () => {
|
|
151
|
+
const { deps, calls } = makeDeps({ builder: { id: 9, github_login: 'mona' }, suggestThrows: true });
|
|
152
|
+
const r = await ib.recordInboundIdea({ discordUserId: GOOD_ID, content: 'idea despite an outage', messageId: 'm' }, deps);
|
|
153
|
+
assert.equal(r.outcome, 'filed', 'a suggester outage costs the hint, never the idea');
|
|
154
|
+
assert.equal(calls.capture[0].suggestedGoalId, null);
|
|
155
|
+
assert.equal(calls.capture[0].suggestedVersion, null);
|
|
156
|
+
assert.equal('goalId' in calls.capture[0], false);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
await t('recordInboundIdea: default-version lookup throws → filing still succeeds, version hint null', async () => {
|
|
160
|
+
const { deps, calls } = makeDeps({ builder: { id: 9, github_login: 'mona' }, goal: null, listVersionsThrows: true });
|
|
161
|
+
const r = await ib.recordInboundIdea({ discordUserId: GOOD_ID, content: 'idea despite an outage', messageId: 'm' }, deps);
|
|
162
|
+
assert.equal(r.outcome, 'filed', 'a version-lookup outage costs the hint, never the idea');
|
|
163
|
+
assert.equal(calls.capture[0].suggestedVersion, null);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
await t('mostRecentBuildingVersionId: pure — building-only, latest started_at, empty/absent → null', () => {
|
|
167
|
+
assert.equal(ib.mostRecentBuildingVersionId([]), null);
|
|
168
|
+
assert.equal(ib.mostRecentBuildingVersionId(undefined), null);
|
|
169
|
+
assert.equal(ib.mostRecentBuildingVersionId([{ id: 'A', status: 'planning', started_at: '2026-01-01' }]), null);
|
|
170
|
+
assert.equal(
|
|
171
|
+
ib.mostRecentBuildingVersionId([
|
|
172
|
+
{ id: 'OLD', status: 'building', started_at: '2026-01-01' },
|
|
173
|
+
{ id: 'NEW', status: 'building', started_at: '2026-06-01' },
|
|
174
|
+
]),
|
|
175
|
+
'NEW'
|
|
176
|
+
);
|
|
177
|
+
});
|
|
178
|
+
|
|
91
179
|
await t('recordInboundIdea: empty content → ignored, nothing filed', async () => {
|
|
92
180
|
const { deps, calls } = makeDeps();
|
|
93
181
|
const r = await ib.recordInboundIdea({ discordUserId: GOOD_ID, content: ' ', messageId: 'm' }, deps);
|
package/tests/goal_advisory.mjs
CHANGED
|
@@ -55,10 +55,30 @@ await test('catch_all — landing in "<version> — general" is a bucket, not a
|
|
|
55
55
|
assert.equal(a.goal_id, 3, 'names the catch-all so a caller can PATCH away from it');
|
|
56
56
|
});
|
|
57
57
|
|
|
58
|
+
// task 1003214 (idea 1000828): a bug in the catch-all is where it belongs, so the
|
|
59
|
+
// advisory goes silent (reuses the same null the module already uses for "no
|
|
60
|
+
// advice needed").
|
|
61
|
+
await test("kind='bug' in the catch-all is quiet — a fix is where it belongs", () => {
|
|
62
|
+
assert.equal(adviseGoal({ versionId: V, goalId: 3, goalTitle: generalGoalTitle(V), kind: 'bug' }), null);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
await test("kind='feature' in the catch-all still gets the nudge — quiet is bug-only", () => {
|
|
66
|
+
const a = adviseGoal({ versionId: V, goalId: 3, goalTitle: generalGoalTitle(V), kind: 'feature' });
|
|
67
|
+
assert.equal(a.reason, 'catch_all');
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
await test("kind='bug' with no goal at all is quiet too — a fix needs no home to be filed correctly", () => {
|
|
71
|
+
assert.equal(adviseGoal({ versionId: V, goalId: null, kind: 'bug' }), null);
|
|
72
|
+
});
|
|
73
|
+
|
|
58
74
|
await test('a REAL goal is silent — null, not an empty object', () => {
|
|
59
75
|
assert.equal(adviseGoal({ versionId: V, goalId: 1000025, goalTitle: 'Builder experience — hall, dev box, and skills' }), null);
|
|
60
76
|
});
|
|
61
77
|
|
|
78
|
+
await test("kind='bug' under a real goal is silent too — already silent, kind changes nothing", () => {
|
|
79
|
+
assert.equal(adviseGoal({ versionId: V, goalId: 1000025, goalTitle: 'Builder experience — hall, dev box, and skills', kind: 'bug' }), null);
|
|
80
|
+
});
|
|
81
|
+
|
|
62
82
|
// The bug this guards: a near-miss title must not be mistaken for the catch-all.
|
|
63
83
|
// db.generalGoalIdForVersion resolves the SAME string, so if these two ever disagree
|
|
64
84
|
// a task would be filed into a goal the advisory then calls orphaned (or vice versa).
|
|
@@ -164,6 +184,14 @@ await test('buildGoalAdvisory: the catch-all is detected and named', async () =>
|
|
|
164
184
|
assert.equal(a.goal_id, 3);
|
|
165
185
|
});
|
|
166
186
|
|
|
187
|
+
// task 1003214: the full POST /tasks path (buildGoalAdvisory reads task.kind
|
|
188
|
+
// off the already-committed row) for a bug landed in the catch-all — silent.
|
|
189
|
+
await test("buildGoalAdvisory: kind='bug' in the catch-all yields null end to end", async () => {
|
|
190
|
+
const db = stubDb({ goal: { id: 3, title: generalGoalTitle(V) }, goals: [] });
|
|
191
|
+
const a = await buildGoalAdvisory({ db, versionId: V, task: { id: 7, goal_id: 3, kind: 'bug' } });
|
|
192
|
+
assert.equal(a, null);
|
|
193
|
+
});
|
|
194
|
+
|
|
167
195
|
// The invariant a future refactor will quietly break. Both DB calls are covered:
|
|
168
196
|
// getGoal is only reached when goal_id is set, listGoals only on the unhappy path.
|
|
169
197
|
await test('NON-BLOCKING: buildGoalAdvisory returns null when getGoal throws', async () => {
|
|
@@ -269,8 +269,14 @@ const MD_SRC = read('md-reader.js');
|
|
|
269
269
|
// grade / sessionLogs / claims (task 1003437): left undefined, the stub answers
|
|
270
270
|
// like a client that never asked (the key is absent); null or an array answers
|
|
271
271
|
// like the read did. `requests` records every path the page asked for.
|
|
272
|
-
|
|
272
|
+
// goalsList (task 1003063 / R10) feeds GET /goals?version= — the move-to-goal
|
|
273
|
+
// picker's options. `patch` controls the response to PATCH /tasks/:id: null
|
|
274
|
+
// (default) echoes the task back at 200 with goal_id applied from the request
|
|
275
|
+
// body (so a reload's GET reflects the move); { status, data } refuses it with
|
|
276
|
+
// that shape instead, for the refusal-surfacing tests.
|
|
277
|
+
function renderTask(task, { lastClaim = null, viewer = null, goal = null, goalsList = null, patch = null, status = 200, search = '', referrer = '', withReader = true, grade, sessionLogs, claims } = {}) {
|
|
273
278
|
const requests = [];
|
|
279
|
+
const toasts = [];
|
|
274
280
|
const els = new Map();
|
|
275
281
|
const makeEl = () => ({
|
|
276
282
|
dataset: {}, style: {}, textContent: '', hidden: false, disabled: false, _html: '',
|
|
@@ -294,8 +300,18 @@ function renderTask(task, { lastClaim = null, viewer = null, goal = null, status
|
|
|
294
300
|
addEventListener(type, fn) { if (type === 'DOMContentLoaded') onReady = fn; },
|
|
295
301
|
};
|
|
296
302
|
const client = {
|
|
297
|
-
request: async (method, p) => {
|
|
303
|
+
request: async (method, p, opts) => {
|
|
298
304
|
requests.push(p);
|
|
305
|
+
// PATCH must be checked before the generic /tasks/\d+ read below — both
|
|
306
|
+
// match the same path shape, and the read branch ignores method.
|
|
307
|
+
if (method === 'PATCH' && /\/tasks\/\d+$/.test(p)) {
|
|
308
|
+
if (patch && patch.status && patch.status !== 200) {
|
|
309
|
+
return { status: patch.status, ok: false, data: patch.data || null };
|
|
310
|
+
}
|
|
311
|
+
const body = (opts && opts.body) || {};
|
|
312
|
+
if (body.goal_id != null) task.goal_id = body.goal_id; // same object load() re-reads on reload
|
|
313
|
+
return { status: 200, ok: true, data: { task } };
|
|
314
|
+
}
|
|
299
315
|
if (/\/tasks\/\d+/.test(p)) {
|
|
300
316
|
if (status !== 200) return { status, ok: false, data: null };
|
|
301
317
|
const data = { task, last_claim: lastClaim };
|
|
@@ -305,6 +321,7 @@ function renderTask(task, { lastClaim = null, viewer = null, goal = null, status
|
|
|
305
321
|
return { status: 200, ok: true, data };
|
|
306
322
|
}
|
|
307
323
|
if (/\/me$/.test(p)) return { status: 200, ok: true, data: { builder: viewer } };
|
|
324
|
+
if (/\/goals\?/.test(p)) return { status: 200, ok: true, data: { goals: goalsList || [] } };
|
|
308
325
|
if (/\/goals\//.test(p)) return { status: 200, ok: true, data: { goal } };
|
|
309
326
|
return { status: 404, ok: false, data: null };
|
|
310
327
|
},
|
|
@@ -320,7 +337,7 @@ function renderTask(task, { lastClaim = null, viewer = null, goal = null, status
|
|
|
320
337
|
errCode: (d) => (d && d.error && d.error.code) || null,
|
|
321
338
|
errMessage: (d) => (d && d.error && d.error.message) || null,
|
|
322
339
|
categoryChip: () => '',
|
|
323
|
-
toast() {},
|
|
340
|
+
toast(msg) { toasts.push(String(msg)); },
|
|
324
341
|
},
|
|
325
342
|
location: { hostname: 'builders.cloudbongos.com', pathname: `/task/${task && task.id ? task.id : '1'}`, search, origin: 'https://builders.cloudbongos.com', assign() {} },
|
|
326
343
|
addEventListener() {},
|
|
@@ -339,6 +356,7 @@ function renderTask(task, { lastClaim = null, viewer = null, goal = null, status
|
|
|
339
356
|
sub: () => documentObj.getElementById('task-sub').textContent,
|
|
340
357
|
el: (id) => documentObj.getElementById(id),
|
|
341
358
|
requests,
|
|
359
|
+
toasts,
|
|
342
360
|
};
|
|
343
361
|
}
|
|
344
362
|
|
|
@@ -750,3 +768,53 @@ test('the mocks no longer mark the two regions proposed — they are built', ()
|
|
|
750
768
|
assert.ok(!mock.includes('<em>proposed</em>'), `${v}: no proposed eyebrow`);
|
|
751
769
|
}
|
|
752
770
|
});
|
|
771
|
+
|
|
772
|
+
// ---- 11. the move-to-goal affordance (task 1003063 / R10) -------------------
|
|
773
|
+
//
|
|
774
|
+
// PATCH /tasks/:id {goal_id} (task 1763) existed with no hall control, so a
|
|
775
|
+
// wrong routing pick meant a re-file. Metic+ gets a picker on the record now;
|
|
776
|
+
// the server re-enforces rank + the goal-in-same-version rule (ADR 0016) — this
|
|
777
|
+
// only draws what to offer and reads the server's own refusal verbatim.
|
|
778
|
+
|
|
779
|
+
test('the move-to-goal picker renders for Metic/Archon and not for Xenos/Thetes', async () => {
|
|
780
|
+
for (const rank of ['metic', 'archon']) {
|
|
781
|
+
const r = renderTask(BASE_TASK, { viewer: { rank } });
|
|
782
|
+
await r.boot(); await settle();
|
|
783
|
+
assert.match(r.html(), /id="move-goal-select"/, `${rank} sees the move-to-goal control`);
|
|
784
|
+
}
|
|
785
|
+
for (const rank of ['xenos', 'thetes']) {
|
|
786
|
+
const r = renderTask(BASE_TASK, { viewer: { rank } });
|
|
787
|
+
await r.boot(); await settle();
|
|
788
|
+
assert.doesNotMatch(r.html(), /id="move-goal-select"/, `${rank} must not see the move-to-goal control`);
|
|
789
|
+
}
|
|
790
|
+
});
|
|
791
|
+
|
|
792
|
+
test('a successful move-to-goal PATCH re-renders the goal line against the new goal', async () => {
|
|
793
|
+
const task = { ...BASE_TASK, goal_id: 1000071 };
|
|
794
|
+
const goalsList = [{ id: 1000071, title: 'Old goal', status: 'open' }, { id: 555, title: 'New goal', status: 'open' }];
|
|
795
|
+
const r = renderTask(task, { viewer: { rank: 'metic' }, goal: { id: 1000071, title: 'Old goal' }, goalsList });
|
|
796
|
+
await r.boot(); await settle();
|
|
797
|
+
assert.match(r.html(), /Goal <a href="\/goals#\/goal\/1000071">1000071/, 'starts on the original goal');
|
|
798
|
+
r.el('move-goal-select').value = '555';
|
|
799
|
+
await r.el('move-goal-btn')._handlers.click();
|
|
800
|
+
await settle();
|
|
801
|
+
assert.match(r.html(), /Goal <a href="\/goals#\/goal\/555">555/,
|
|
802
|
+
'the goal line reflects the new goal after the PATCH triggers a reload');
|
|
803
|
+
});
|
|
804
|
+
|
|
805
|
+
test("a move-to-goal refusal (403) surfaces the server's own sentence, verbatim", async () => {
|
|
806
|
+
const task = { ...BASE_TASK, goal_id: 1000071 };
|
|
807
|
+
const goalsList = [{ id: 1000071, title: 'Old goal', status: 'open' }, { id: 555, title: 'New goal', status: 'open' }];
|
|
808
|
+
const serverMsg = 'Only an Archon may reassign a security-sensitive task.';
|
|
809
|
+
const r = renderTask(task, {
|
|
810
|
+
viewer: { rank: 'metic' }, goalsList,
|
|
811
|
+
patch: { status: 403, data: { error: { code: 'rank_forbidden', message: serverMsg } } },
|
|
812
|
+
});
|
|
813
|
+
await r.boot(); await settle();
|
|
814
|
+
r.el('move-goal-select').value = '555';
|
|
815
|
+
await r.el('move-goal-btn')._handlers.click();
|
|
816
|
+
await settle();
|
|
817
|
+
assert.ok(r.toasts.some((m) => m.includes(serverMsg)),
|
|
818
|
+
`the server's own sentence must reach the toast, not a generic fallback — got: ${r.toasts.join(' | ')}`);
|
|
819
|
+
assert.equal(task.goal_id, 1000071, 'a refused PATCH must not move the task locally');
|
|
820
|
+
});
|
package/tests/idea_routing.mjs
CHANGED
|
@@ -213,6 +213,39 @@ await test('a named goal the matrix DECLINES is kept as the core_222 hint, not d
|
|
|
213
213
|
'and the reason says WHY it declined, so a filer is not left guessing');
|
|
214
214
|
});
|
|
215
215
|
|
|
216
|
+
await test('suggestedGoalId (task 1003068, BV1.R12): writes the hint, never routes, for an ORDINARY kind', async () => {
|
|
217
|
+
// The case goalId can't cover: an ordinary feature/bug-classified kind with
|
|
218
|
+
// NO human routing decision (goalId absent) still needs the hint recorded —
|
|
219
|
+
// this is the Discord #ideas classifier's vector. Passing goalId here would
|
|
220
|
+
// ROUTE (create a task) for any non-judgment kind; suggestedGoalId must not.
|
|
221
|
+
reset();
|
|
222
|
+
const pool = fakePool((s) => (/INSERT INTO idea_inbox/.test(s)
|
|
223
|
+
? { rows: [{ ...IDEA_ROW, kind: 'feature', status: 'open', suggested_goal_id: 42 }] } : null));
|
|
224
|
+
const idea = await captureIdea(
|
|
225
|
+
{ title: 'restock overnight', kind: 'feature', suggestedGoalId: 42, capturedBy: 9 },
|
|
226
|
+
{ pool }
|
|
227
|
+
);
|
|
228
|
+
const sqls = seq(pool.queries);
|
|
229
|
+
assert.equal(sqls.length, 1, 'exactly one statement — no transaction, no route');
|
|
230
|
+
const ins = find(pool.queries, /INSERT INTO idea_inbox/);
|
|
231
|
+
assert.match(norm(ins.sql), /'open'/, "status stays 'open'");
|
|
232
|
+
assert.equal(ins.params[6], 42, 'suggestedGoalId is written as the suggested_goal_id hint');
|
|
233
|
+
assert.equal(createTaskCalls.length, 0, 'no task is created — a suggestion is not a decision');
|
|
234
|
+
assert.deepEqual(idea.routing, { routed: false, reason: 'no_goal' },
|
|
235
|
+
'decideLanding never saw a goalId, so the ordinary no-goal reason applies');
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
await test('suggestedGoalId is ignored when goalId is ALSO set — a real decision always wins', async () => {
|
|
239
|
+
reset();
|
|
240
|
+
const pool = fakePool(routableHandler());
|
|
241
|
+
await captureIdea(
|
|
242
|
+
{ title: 'broken', kind: 'bug', goalId: 7, suggestedGoalId: 999, capturedBy: 42, filerRank: 'archon' },
|
|
243
|
+
{ pool }
|
|
244
|
+
);
|
|
245
|
+
assert.equal(createTaskCalls.length, 1, 'goalId still routes exactly as before');
|
|
246
|
+
assert.equal(createTaskCalls[0].args.goalId, 7, 'the routed goal is the real decision, not the hint');
|
|
247
|
+
});
|
|
248
|
+
|
|
216
249
|
await test('the pre-routing validations still fire before any write', async () => {
|
|
217
250
|
reset();
|
|
218
251
|
const pool = fakePool(() => null);
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// tests/planning_session_skill.mjs
|
|
2
|
+
//
|
|
3
|
+
// Static prose assertions on .claude/skills/planning-session/SKILL.md — the
|
|
4
|
+
// skill file IS the deliverable for its own retro tasks (goal 1000071's
|
|
5
|
+
// planning-session retro), so what's checkable is the documented contract,
|
|
6
|
+
// not runtime behavior. Prose is asserted against a whitespace-normalised
|
|
7
|
+
// copy (markdown is hard-wrapped and the checkout reads CRLF back, so a
|
|
8
|
+
// phrase worth asserting will straddle a newline or a \r) — the
|
|
9
|
+
// idea_triage_recommends.mjs precedent.
|
|
10
|
+
//
|
|
11
|
+
// Run: node --test --test-reporter=tap tests/planning_session_skill.mjs
|
|
12
|
+
|
|
13
|
+
import { strict as assert } from 'node:assert';
|
|
14
|
+
import fs from 'node:fs';
|
|
15
|
+
import path from 'node:path';
|
|
16
|
+
import { fileURLToPath } from 'node:url';
|
|
17
|
+
|
|
18
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
19
|
+
const SKILL = fs.readFileSync(path.join(ROOT, '.claude/skills/planning-session/SKILL.md'), 'utf8');
|
|
20
|
+
const NORM = SKILL.replace(/\s+/g, ' ');
|
|
21
|
+
|
|
22
|
+
let passed = 0;
|
|
23
|
+
let failed = 0;
|
|
24
|
+
function test(name, fn) {
|
|
25
|
+
try { fn(); passed++; console.log(` ok ${name}`); }
|
|
26
|
+
catch (err) { failed++; console.error(` FAIL ${name}\n ${err.message}`); }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// --- task 1003076: seed scripts verify their own criterion links -----------
|
|
30
|
+
|
|
31
|
+
test('the seeding contract documents the self-check convention', () => {
|
|
32
|
+
assert.ok(NORM.includes('verifySeedCriteriaLinks'), 'names the helper function');
|
|
33
|
+
assert.ok(NORM.includes('seed-verify-criteria.js'), 'names the shared helper file');
|
|
34
|
+
assert.ok(NORM.includes('GET /api/gds/goals/<id>'), 'names the endpoint the self-check fetches');
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test('the self-check is described as failing loudly on any mismatch, zero included', () => {
|
|
38
|
+
assert.ok(/exit\(1\)`?s? with a loud/.test(NORM), 'documents the loud non-zero exit');
|
|
39
|
+
assert.ok(NORM.includes('zero included'), 'zero is called out explicitly, not just "a mismatch"');
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test('the reference implementation is named', () => {
|
|
43
|
+
assert.ok(NORM.includes('seed-bongos-v1-idea-routing-tasks.js'), 'names the reference seed script');
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test('the retro incident (RETRO2 / learning 1000188) is cited as the reason', () => {
|
|
47
|
+
assert.ok(NORM.includes('learning 1000188'));
|
|
48
|
+
assert.ok(NORM.includes('goal 1000071'));
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test('step 5 (run the seed script) points at the self-check rather than treating exit 0 as sufficient on its own', () => {
|
|
52
|
+
const step5 = NORM.slice(NORM.indexOf('Run the seed script.'));
|
|
53
|
+
assert.ok(step5.slice(0, 400).includes('verifySeedCriteriaLinks'), 'step 5 references the self-check by name');
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
console.log(`\nplanning_session_skill: ${passed} passed, ${failed} failed`);
|
|
57
|
+
if (failed > 0) process.exit(1);
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
// tests/seed_verify_criteria.mjs
|
|
2
|
+
//
|
|
3
|
+
// task 1003076 — the retro on goal 1000071's idea-routing seed: it linked all
|
|
4
|
+
// 15 tasks to the WRONG criteria (positional "Cn" tokens resolving version-
|
|
5
|
+
// wide instead of per-goal, learning 1000188) and still printed success. The
|
|
6
|
+
// mislink was only caught by a manual GET /goals rollup, after the fact.
|
|
7
|
+
//
|
|
8
|
+
// scripts/gds/seed-verify-criteria.js is the fix: every seed script tallies
|
|
9
|
+
// the criterion_ids it actually POSTed, then diffs that plan against
|
|
10
|
+
// GET /api/gds/goals/<id>'s real linked_tasks counts — loud and non-zero-exit
|
|
11
|
+
// on any mismatch, zero included. This file pins the pure diff/tally
|
|
12
|
+
// functions and the exit(1)-on-mismatch behavior with no network dependency.
|
|
13
|
+
//
|
|
14
|
+
// Run: node --test --test-reporter=tap tests/seed_verify_criteria.mjs
|
|
15
|
+
|
|
16
|
+
import { strict as assert } from 'node:assert';
|
|
17
|
+
import { createRequire } from 'node:module';
|
|
18
|
+
|
|
19
|
+
const require = createRequire(import.meta.url);
|
|
20
|
+
const {
|
|
21
|
+
diffCriterionLinks,
|
|
22
|
+
expectedCriterionCounts,
|
|
23
|
+
verifySeedCriteriaLinks,
|
|
24
|
+
} = require('../scripts/gds/seed-verify-criteria.js');
|
|
25
|
+
|
|
26
|
+
let passed = 0;
|
|
27
|
+
let failed = 0;
|
|
28
|
+
function test(name, fn) {
|
|
29
|
+
try { fn(); passed++; console.log(` ok ${name}`); }
|
|
30
|
+
catch (err) { failed++; console.error(` FAIL ${name}\n ${err.message}`); }
|
|
31
|
+
}
|
|
32
|
+
async function testAsync(name, fn) {
|
|
33
|
+
try { await fn(); passed++; console.log(` ok ${name}`); }
|
|
34
|
+
catch (err) { failed++; console.error(` FAIL ${name}\n ${err.message}`); }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// --- expectedCriterionCounts: the plan, tallied from what was POSTed -------
|
|
38
|
+
|
|
39
|
+
test('tallies one count per criterion_id across all tasks', () => {
|
|
40
|
+
const counts = expectedCriterionCounts([
|
|
41
|
+
['bug-lands-in-its-goal', 'no-route-loses-the-author'],
|
|
42
|
+
['bug-lands-in-its-goal'],
|
|
43
|
+
['inbox-holds-only-homeless'],
|
|
44
|
+
]);
|
|
45
|
+
assert.deepEqual(counts, {
|
|
46
|
+
'bug-lands-in-its-goal': 2,
|
|
47
|
+
'no-route-loses-the-author': 1,
|
|
48
|
+
'inbox-holds-only-homeless': 1,
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test('a criterion no task cites is simply absent from the plan', () => {
|
|
53
|
+
const counts = expectedCriterionCounts([['a'], ['a']]);
|
|
54
|
+
assert.equal(counts.b, undefined);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
// --- diffCriterionLinks: expected vs the real GET /goals/:id counts --------
|
|
58
|
+
|
|
59
|
+
test('matching counts produce no mismatches', () => {
|
|
60
|
+
const diff = diffCriterionLinks(
|
|
61
|
+
{ a: 2, b: 1 },
|
|
62
|
+
[{ criterion_id: 'a', linked_tasks: 2 }, { criterion_id: 'b', linked_tasks: 1 }],
|
|
63
|
+
);
|
|
64
|
+
assert.deepEqual(diff, []);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test('a criterion linked to zero tasks when N were planned is caught', () => {
|
|
68
|
+
// The exact shape of the RETRO2 incident: the goal's own criteria row comes
|
|
69
|
+
// back with linked_tasks: 0 because every task actually landed under a
|
|
70
|
+
// DIFFERENT (wrong) criterion.
|
|
71
|
+
const diff = diffCriterionLinks(
|
|
72
|
+
{ 'bug-lands-in-its-goal': 3 },
|
|
73
|
+
[{ criterion_id: 'bug-lands-in-its-goal', linked_tasks: 0 }],
|
|
74
|
+
);
|
|
75
|
+
assert.equal(diff.length, 1);
|
|
76
|
+
assert.deepEqual(diff[0], { criterion_id: 'bug-lands-in-its-goal', expected: 3, actual: 0 });
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test('an over-linked criterion is caught too, not just zero', () => {
|
|
80
|
+
const diff = diffCriterionLinks(
|
|
81
|
+
{ a: 1 },
|
|
82
|
+
[{ criterion_id: 'a', linked_tasks: 4 }],
|
|
83
|
+
);
|
|
84
|
+
assert.deepEqual(diff, [{ criterion_id: 'a', expected: 1, actual: 4 }]);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test('linked_tasks arrives as a string (pg count) and still compares numerically', () => {
|
|
88
|
+
const diff = diffCriterionLinks({ a: 2 }, [{ criterion_id: 'a', linked_tasks: '2' }]);
|
|
89
|
+
assert.deepEqual(diff, []);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test('a criterion missing from the GET /goals response reads as zero, not skipped', () => {
|
|
93
|
+
const diff = diffCriterionLinks({ a: 1, b: 2 }, [{ criterion_id: 'a', linked_tasks: 1 }]);
|
|
94
|
+
assert.deepEqual(diff, [{ criterion_id: 'b', expected: 2, actual: 0 }]);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
// --- verifySeedCriteriaLinks: the loud exit(1) wrapper ----------------------
|
|
98
|
+
|
|
99
|
+
await testAsync('zero links where N were planned exits non-zero with a loud message', async () => {
|
|
100
|
+
const logs = [];
|
|
101
|
+
let exitCode = null;
|
|
102
|
+
await verifySeedCriteriaLinks({
|
|
103
|
+
goalId: 1000071,
|
|
104
|
+
expectedCounts: { 'bug-lands-in-its-goal': 3 },
|
|
105
|
+
fetchGoal: async () => ({
|
|
106
|
+
ok: true, status: 200,
|
|
107
|
+
json: { criteria: [{ criterion_id: 'bug-lands-in-its-goal', linked_tasks: 0 }] },
|
|
108
|
+
}),
|
|
109
|
+
log: (line) => logs.push(line),
|
|
110
|
+
exit: (code) => { exitCode = code; },
|
|
111
|
+
});
|
|
112
|
+
assert.equal(exitCode, 1);
|
|
113
|
+
assert.ok(logs.some((l) => l.includes('VERIFY FAILED')), 'expected a loud VERIFY FAILED line');
|
|
114
|
+
assert.ok(logs.some((l) => l.includes('bug-lands-in-its-goal')), 'expected the offending criterion named');
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
await testAsync('a plan that matches reality does not exit and logs VERIFY OK', async () => {
|
|
118
|
+
const logs = [];
|
|
119
|
+
let exitCalled = false;
|
|
120
|
+
await verifySeedCriteriaLinks({
|
|
121
|
+
goalId: 1000071,
|
|
122
|
+
expectedCounts: { a: 2, b: 1 },
|
|
123
|
+
fetchGoal: async () => ({
|
|
124
|
+
ok: true, status: 200,
|
|
125
|
+
json: { criteria: [{ criterion_id: 'a', linked_tasks: 2 }, { criterion_id: 'b', linked_tasks: 1 }] },
|
|
126
|
+
}),
|
|
127
|
+
log: (line) => logs.push(line),
|
|
128
|
+
exit: () => { exitCalled = true; },
|
|
129
|
+
});
|
|
130
|
+
assert.equal(exitCalled, false);
|
|
131
|
+
assert.ok(logs.some((l) => l.includes('VERIFY OK')));
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
await testAsync('a failed/malformed GET is treated as a verification failure, not silence', async () => {
|
|
135
|
+
let exitCode = null;
|
|
136
|
+
const logs = [];
|
|
137
|
+
await verifySeedCriteriaLinks({
|
|
138
|
+
goalId: 1000071,
|
|
139
|
+
expectedCounts: { a: 1 },
|
|
140
|
+
fetchGoal: async () => ({ ok: false, status: 500, json: null }),
|
|
141
|
+
log: (line) => logs.push(line),
|
|
142
|
+
exit: (code) => { exitCode = code; },
|
|
143
|
+
});
|
|
144
|
+
assert.equal(exitCode, 1);
|
|
145
|
+
assert.ok(logs.some((l) => l.includes('VERIFY FAILED')));
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
console.log(`\nseed_verify_criteria: ${passed} passed, ${failed} failed`);
|
|
149
|
+
if (failed > 0) process.exit(1);
|