@bongos/core 1.19.496 → 1.19.498
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 +47 -27
- package/.claude/skills/planning-session/SKILL.md +3 -1
- package/docs/copy-inventory.md +70 -56
- package/docs/copy-registry.json +201 -74
- 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/ideas.css +5 -0
- package/modules/hall-ui/public/ideas.html +57 -0
- package/modules/hall-ui/public/ideas.js +173 -0
- package/modules/hall-ui/public/task.js +87 -3
- package/modules/ideas/inbox.js +8 -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/hall_record_world.mjs +71 -3
- package/tests/idea_routing.mjs +33 -0
- package/tests/idea_spark_hall.mjs +136 -2
- package/tests/planning_session_skill.mjs +57 -0
- package/tests/seed_verify_criteria.mjs +149 -0
package/scripts/gds/capture.js
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
// [--body-file path] \
|
|
9
9
|
// [--version V1|PMS-V2|GDS-V3] \
|
|
10
10
|
// [--priority N] \
|
|
11
|
+
// [--goal N | --goal none] \
|
|
11
12
|
// [--spark] \
|
|
12
13
|
// [--full --fields-file path.json] \
|
|
13
14
|
// [--no-prompt]
|
|
@@ -38,6 +39,27 @@
|
|
|
38
39
|
// --version → most recent version with status='building' (queried from
|
|
39
40
|
// /api/gds/versions); leave null if there isn't one
|
|
40
41
|
// --priority → null (set later at triage time)
|
|
42
|
+
// --goal → capture-time ROUTING (task 1003070, R09 of goal 1000071,
|
|
43
|
+
// interview decision 4). Naming a goal turns this filing into a
|
|
44
|
+
// task IN that goal in the same request (POST /inbox's own
|
|
45
|
+
// landing matrix — task 1003065 — still decides whether it
|
|
46
|
+
// hard-lands 'ready' or soft-lands 'backlog'; this CLI duplicates
|
|
47
|
+
// none of that, it only decides WHICH goal to send). Three ways:
|
|
48
|
+
// --goal N → route to goal N, verbatim.
|
|
49
|
+
// --goal none → explicitly homeless: no goal_id is sent, and
|
|
50
|
+
// the active-claim default below is skipped —
|
|
51
|
+
// an opt-out that still guessed a goal would not
|
|
52
|
+
// be an opt-out.
|
|
53
|
+
// (neither) → default to the ACTIVE CLAIM's goal, if this
|
|
54
|
+
// builder holds one and its task has a goal
|
|
55
|
+
// ("capturing mid-work almost always concerns
|
|
56
|
+
// the work" — the interview's own words). With
|
|
57
|
+
// no default to offer, the R07 suggester
|
|
58
|
+
// (GET /goals/suggestions) prints its best guess
|
|
59
|
+
// as a one-line hint — never an auto-pick; C5 is
|
|
60
|
+
// "the system OFFERS the home", not chooses it.
|
|
61
|
+
// Both the active-claim lookup and the suggester are best-effort:
|
|
62
|
+
// either failing degrades to "no goal", never to a fatal error.
|
|
41
63
|
//
|
|
42
64
|
// Exit codes:
|
|
43
65
|
// 0 captured
|
|
@@ -71,6 +93,103 @@ function formatCaptureFailure(status, data) {
|
|
|
71
93
|
return `capture failed (${status}): ${rendered}`;
|
|
72
94
|
}
|
|
73
95
|
|
|
96
|
+
// parseGoalArg — pure parse of --goal's raw value (task 1003070 / R09). 'none'
|
|
97
|
+
// (case-insensitive) is the explicit opt-out; anything else must be a positive
|
|
98
|
+
// integer id. Never throws — a bad value comes back as { kind: 'invalid' } so
|
|
99
|
+
// main() can print one usage line and exit 2, same shape as the --priority check
|
|
100
|
+
// above it.
|
|
101
|
+
function parseGoalArg(raw) {
|
|
102
|
+
if (raw == null) return { kind: 'unset' };
|
|
103
|
+
const trimmed = String(raw).trim();
|
|
104
|
+
if (trimmed.toLowerCase() === 'none') return { kind: 'none' };
|
|
105
|
+
const n = Number(trimmed);
|
|
106
|
+
if (Number.isInteger(n) && n > 0) return { kind: 'id', id: n };
|
|
107
|
+
return { kind: 'invalid' };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// resolveActiveClaimGoal — the active-claim default (interview decision 4). /me's
|
|
111
|
+
// active_claims rows (the same read claim.js/release.js already use to find "the
|
|
112
|
+
// claim I'm holding") carry the task's title but not its goal, so this adds one
|
|
113
|
+
// GET /tasks/:id to read it. Returns null — never throws — on no session, no
|
|
114
|
+
// active claim, or a claimed task with no goal: a claim-lookup hiccup must not
|
|
115
|
+
// block a capture that would otherwise have filed fine with no goal at all.
|
|
116
|
+
async function resolveActiveClaimGoal({ client = cliClient } = {}) {
|
|
117
|
+
try {
|
|
118
|
+
const api = await client();
|
|
119
|
+
const me = await api.me.getMe();
|
|
120
|
+
if (!me.ok || !me.data) return null;
|
|
121
|
+
const claims = me.data.active_claims || (me.data.active_claim ? [me.data.active_claim] : []);
|
|
122
|
+
const claim = claims[0];
|
|
123
|
+
if (!claim || claim.task_id == null) return null;
|
|
124
|
+
const t = await api.tasks.getTasksId({ id: claim.task_id });
|
|
125
|
+
if (!t.ok || !t.data || !t.data.task) return null;
|
|
126
|
+
const goalId = Number(t.data.task.goal_id);
|
|
127
|
+
if (!Number.isInteger(goalId) || goalId <= 0) return null;
|
|
128
|
+
return { goalId, taskId: claim.task_id, taskTitle: claim.title || t.data.task.title };
|
|
129
|
+
} catch (_) {
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// fetchGoalSuggestion — the R07 suggester (GET /goals/suggestions), consulted
|
|
135
|
+
// only when nothing else named a goal: criterion C5's "the filer never has to
|
|
136
|
+
// know the goal map" promise. Returns the top candidate or null — no confident
|
|
137
|
+
// match, no version to rank against, and any outage all degrade the same way,
|
|
138
|
+
// because a suggester hiccup must never block a capture that would otherwise
|
|
139
|
+
// file fine with no goal.
|
|
140
|
+
async function fetchGoalSuggestion({ client = cliClient, version, title, kind } = {}) {
|
|
141
|
+
if (!version) return null;
|
|
142
|
+
try {
|
|
143
|
+
const api = await client();
|
|
144
|
+
// The GENERATED typed method (triage.js's precedent) — a path/param rename
|
|
145
|
+
// reds the client build instead of failing silently at runtime.
|
|
146
|
+
const r = await api.goals.getGoalsSuggestions({ query: { version, title: title || '', kind, limit: 1 } });
|
|
147
|
+
const top = r.ok && r.data && Array.isArray(r.data.suggestions) ? r.data.suggestions[0] : null;
|
|
148
|
+
return top ? { id: top.id, title: top.title, score: top.score } : null;
|
|
149
|
+
} catch (_) {
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// sanitizeForTerminal — strip C0 control bytes (ANSI escapes among them) before
|
|
155
|
+
// a builder/goal TITLE reaches the terminal unescaped. A title is user-authored
|
|
156
|
+
// text this CLI does not own, and printing it raw would let one embed
|
|
157
|
+
// cursor-control sequences in output a caller reads as plain status text
|
|
158
|
+
// (grader finding, task 1003070).
|
|
159
|
+
function sanitizeForTerminal(s) {
|
|
160
|
+
return String(s == null ? '' : s).replace(/[\x00-\x1f\x7f]/g, '');
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// resolveGoalForCapture — the WHOLE of --goal resolution for one capture, in the
|
|
164
|
+
// interview's own precedence: an explicit id wins outright; 'none' is a hard
|
|
165
|
+
// opt-out (no default, no hint); otherwise the active-claim default runs, and
|
|
166
|
+
// only when THAT yields nothing does the suggester offer a hint. `lines` are
|
|
167
|
+
// ready-to-print strings rather than direct console.log calls, so the decision
|
|
168
|
+
// is testable without capturing stdout. Never throws (both collaborators above
|
|
169
|
+
// already degrade to null on their own).
|
|
170
|
+
async function resolveGoalForCapture({ goalArg, version, title, kind, client = cliClient } = {}) {
|
|
171
|
+
const parsed = parseGoalArg(goalArg);
|
|
172
|
+
if (parsed.kind === 'id') return { goalId: parsed.id, lines: [] };
|
|
173
|
+
if (parsed.kind === 'none') return { goalId: null, lines: [] };
|
|
174
|
+
|
|
175
|
+
const defaulted = await resolveActiveClaimGoal({ client });
|
|
176
|
+
if (defaulted) {
|
|
177
|
+
return {
|
|
178
|
+
goalId: defaulted.goalId,
|
|
179
|
+
lines: [`↪ defaulting --goal to ${defaulted.goalId} from your active claim on task #${defaulted.taskId} ("${sanitizeForTerminal(defaulted.taskTitle)}") — override with --goal <id>, or --goal none to file homeless.`],
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const hint = await fetchGoalSuggestion({ client, version, title, kind });
|
|
184
|
+
if (hint) {
|
|
185
|
+
return {
|
|
186
|
+
goalId: null,
|
|
187
|
+
lines: [`💡 possible home: goal ${hint.id} "${sanitizeForTerminal(hint.title)}" (score ${hint.score}) — re-run with --goal ${hint.id} (or --goal none to stay homeless on purpose).`],
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
return { goalId: null, lines: [] };
|
|
191
|
+
}
|
|
192
|
+
|
|
74
193
|
// DEFAULT IS NO, and only an explicit yes counts. Extracted so the done-when
|
|
75
194
|
// ("declinable in one keystroke") is a testable predicate rather than a regex
|
|
76
195
|
// buried in a readline callback: a bare Enter, a stray space, an EOF (null), or
|
|
@@ -111,6 +230,7 @@ async function main() {
|
|
|
111
230
|
const title = args[0];
|
|
112
231
|
if (!title || title.startsWith('--')) {
|
|
113
232
|
console.error('usage: node scripts/gds/capture.js "title" [--kind feature] [--body-file path] [--version V1|PMS-V2] [--priority N]');
|
|
233
|
+
console.error(' ... [--goal N | --goal none] route into goal N; "none" files homeless on purpose');
|
|
114
234
|
console.error(' ... [--spark] offer it up for someone else to develop');
|
|
115
235
|
console.error(' ... [--full --fields-file f.json] file a Full Idea (five fields; see spark.js --template)');
|
|
116
236
|
console.error(' ... [--no-prompt] never ask anything (default when not a terminal)');
|
|
@@ -125,6 +245,12 @@ async function main() {
|
|
|
125
245
|
const wantFull = hasFlag('--full', args);
|
|
126
246
|
const fieldsFile = arg('--fields-file', args);
|
|
127
247
|
const noPrompt = hasFlag('--no-prompt', args);
|
|
248
|
+
const goalArg = arg('--goal', args);
|
|
249
|
+
|
|
250
|
+
if (parseGoalArg(goalArg).kind === 'invalid') {
|
|
251
|
+
console.error(`--goal must be a positive goal id, or the literal "none" to file homeless (got "${goalArg}").`);
|
|
252
|
+
process.exit(2);
|
|
253
|
+
}
|
|
128
254
|
|
|
129
255
|
// Refused rather than resolved: --spark and --full are contradictory requests
|
|
130
256
|
// (a spark is a thought offered up BECAUSE it is not developed), and silently
|
|
@@ -182,6 +308,13 @@ async function main() {
|
|
|
182
308
|
|
|
183
309
|
const version = versionFromArg || (await resolveDefaultVersion());
|
|
184
310
|
|
|
311
|
+
// --goal resolution (task 1003070 / R09): explicit id, explicit 'none', the
|
|
312
|
+
// active-claim default, or a suggester hint — see resolveGoalForCapture's own
|
|
313
|
+
// header for the precedence. Printed before the capture call so the operator
|
|
314
|
+
// sees the routing decision as context, not an afterthought.
|
|
315
|
+
const goalResolution = await resolveGoalForCapture({ goalArg, version, title, kind: kindFromArg });
|
|
316
|
+
for (const line of goalResolution.lines) console.log(line);
|
|
317
|
+
|
|
185
318
|
const payload = {
|
|
186
319
|
title,
|
|
187
320
|
body_md: bodyMd,
|
|
@@ -189,6 +322,7 @@ async function main() {
|
|
|
189
322
|
suggested_priority: priority,
|
|
190
323
|
};
|
|
191
324
|
if (kindFromArg) payload.kind = kindFromArg;
|
|
325
|
+
if (goalResolution.goalId != null) payload.goal_id = goalResolution.goalId;
|
|
192
326
|
if (wantFull) {
|
|
193
327
|
// The server validates these against the R141 declaration and scores them
|
|
194
328
|
// with R142 — this CLI sends them and states nothing about their shape.
|
|
@@ -199,11 +333,14 @@ async function main() {
|
|
|
199
333
|
const api = await cliClient();
|
|
200
334
|
const r = await api.inbox.postInbox({ body: payload });
|
|
201
335
|
if (!r.ok) {
|
|
336
|
+
// Surfaced verbatim, on purpose (task 1003070): the server-side rank matrix
|
|
337
|
+
// (task 1003066) is the one place that decides whether a routed filing is
|
|
338
|
+
// authorized, and this CLI does not re-derive or soften its refusal.
|
|
202
339
|
console.error(formatCaptureFailure(r.status, r.data));
|
|
203
340
|
process.exit(1);
|
|
204
341
|
}
|
|
205
342
|
|
|
206
|
-
const { idea, open_count, kind_auto } = r.data;
|
|
343
|
+
const { idea, open_count, kind_auto, routed_task } = r.data;
|
|
207
344
|
const kindLabel = kind_auto ? `${idea.kind} (auto)` : idea.kind;
|
|
208
345
|
const versionLabel = idea.suggested_version || 'none';
|
|
209
346
|
const priorityLabel = idea.suggested_priority == null ? 'null' : `P${idea.suggested_priority}`;
|
|
@@ -212,6 +349,15 @@ async function main() {
|
|
|
212
349
|
console.log(
|
|
213
350
|
`Captured idea #${idea.id}: kind=${kindLabel}, version=${versionLabel}, priority=${priorityLabel}${gradeLabel}`
|
|
214
351
|
);
|
|
352
|
+
// task 1003070 (R09): say where a named goal actually landed. `idea.routing`
|
|
353
|
+
// is always present when goal_id was sent (routed, or a named reason it
|
|
354
|
+
// wasn't — POST /inbox's own landing matrix decides, not this CLI).
|
|
355
|
+
if (routed_task) {
|
|
356
|
+
console.log(` → routed into goal ${idea.routing?.goal_id ?? goalResolution.goalId} as task #${routed_task.id} (${idea.routing?.status || routed_task.status})`);
|
|
357
|
+
} else if (goalResolution.goalId != null) {
|
|
358
|
+
const reason = idea.routing?.reason;
|
|
359
|
+
console.log(` (goal ${goalResolution.goalId} named, but filed to the inbox instead${reason ? ` — ${reason}` : ''})`);
|
|
360
|
+
}
|
|
215
361
|
if (wantFull) {
|
|
216
362
|
// The server is the authority on whether the grade write landed: capture is
|
|
217
363
|
// FAIL-OPEN on grading (R143), so an idea can land as quick with a warning.
|
|
@@ -257,7 +403,10 @@ async function main() {
|
|
|
257
403
|
// referred to; promotion to a task re-runs the detection automatically.
|
|
258
404
|
}
|
|
259
405
|
|
|
260
|
-
module.exports = {
|
|
406
|
+
module.exports = {
|
|
407
|
+
resolveDefaultVersion, formatCaptureFailure, acceptsOffer,
|
|
408
|
+
parseGoalArg, resolveActiveClaimGoal, fetchGoalSuggestion, resolveGoalForCapture, sanitizeForTerminal,
|
|
409
|
+
};
|
|
261
410
|
|
|
262
411
|
if (require.main === module) {
|
|
263
412
|
main().catch((err) => {
|
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
// Seed the tasks for goal 1000071 "Idea routing — work that has a home lands in it" (BONGOS-V1).
|
|
2
2
|
// Source of truth: docs/specs/bongos-v1-idea-routing.md (status: final).
|
|
3
3
|
// Idempotent on (source, source_ref). Re-running resolves to the existing rows.
|
|
4
|
+
// Reference implementation (task 1003076) for the seeding contract's self-check:
|
|
5
|
+
// ends by tallying the criterion_ids it actually POSTed and diffing that plan
|
|
6
|
+
// against GET /goals/<id>'s real linked_tasks — see seed-verify-criteria.js.
|
|
4
7
|
//
|
|
5
8
|
// Run: node scripts/gds/seed-bongos-v1-idea-routing-tasks.js
|
|
6
9
|
const os = require('os');
|
|
10
|
+
const { expectedCriterionCounts, verifySeedCriteriaLinks } = require('./seed-verify-criteria');
|
|
7
11
|
const sess = require(os.homedir() + '/.config/cloudbongos/gds-session.json');
|
|
8
12
|
const BASE = sess.api_base.replace(/\/$/, '');
|
|
9
13
|
const TOKEN = sess.token;
|
|
@@ -73,14 +77,24 @@ const ROOTS = ['R02', 'R03', 'R07', 'R10', 'R14']; // zero-dep, non-spike → op
|
|
|
73
77
|
(async () => {
|
|
74
78
|
const idByRef = {};
|
|
75
79
|
let created = 0, existing = 0;
|
|
80
|
+
// Resolved (post-CRIT-mapping) criterion_ids per task — the plan the final
|
|
81
|
+
// self-check diffs against, kept in lockstep with what's actually POSTed
|
|
82
|
+
// below rather than re-derived from the raw "Cn" tokens.
|
|
83
|
+
const criterionIdListsPerTask = [];
|
|
76
84
|
console.log(`Seeding ${TASKS.length} tasks under goal ${GOAL} (${VER})…`);
|
|
77
85
|
for (const t of TASKS) {
|
|
86
|
+
const criterionIds = t.crit.map((c) => CRIT[c]);
|
|
78
87
|
const r = await api('POST', '/tasks', {
|
|
79
88
|
version_id: VER, goal_id: GOAL, title: t.title, description: t.desc,
|
|
80
89
|
kind: t.kind, discipline: t.disc, priority: t.prio, est_minutes: t.est,
|
|
81
|
-
criterion_ids:
|
|
90
|
+
criterion_ids: criterionIds, source: SRC, source_ref: `bongos-v1-idea-routing-${t.ref}`,
|
|
82
91
|
});
|
|
83
92
|
if (!r.ok) { console.log(` ${t.ref}: FAILED ${r.status} ${JSON.stringify(r.json)}`); continue; }
|
|
93
|
+
// Only a task that actually landed contributes to the self-check's plan —
|
|
94
|
+
// a failed POST is already reported above as FAILED, not as a criterion
|
|
95
|
+
// mismatch (task 1003076 grade finding: counting it here regardless of
|
|
96
|
+
// r.ok blamed a real create failure on the wrong cause).
|
|
97
|
+
criterionIdListsPerTask.push(criterionIds);
|
|
84
98
|
const task = r.json.task || r.json;
|
|
85
99
|
idByRef[t.ref] = task.id;
|
|
86
100
|
if (r.json.idempotent || r.json.existing) existing++; else created++;
|
|
@@ -118,4 +132,14 @@ const ROOTS = ['R02', 'R03', 'R07', 'R10', 'R14']; // zero-dep, non-spike → op
|
|
|
118
132
|
|
|
119
133
|
console.log(`\nDONE. created=${created} existing=${existing} depEdges=${edges}. Roots opened: ${ROOTS.join(', ')}.`);
|
|
120
134
|
console.log('ref→id:', JSON.stringify(idByRef));
|
|
135
|
+
|
|
136
|
+
// Self-check (task 1003076): fetch the goal back and confirm every
|
|
137
|
+
// criterion's linked_tasks matches what was actually POSTed — exits 1 loudly
|
|
138
|
+
// on any mismatch instead of a seed that mislinks and reports success.
|
|
139
|
+
console.log('Verifying criterion links against the plan…');
|
|
140
|
+
await verifySeedCriteriaLinks({
|
|
141
|
+
goalId: GOAL,
|
|
142
|
+
expectedCounts: expectedCriterionCounts(criterionIdListsPerTask),
|
|
143
|
+
fetchGoal: (id) => api('GET', `/goals/${id}`),
|
|
144
|
+
});
|
|
121
145
|
})();
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// scripts/gds/seed-verify-criteria.js — the seed-script self-check: did the
|
|
2
|
+
// criterion links actually land where the plan said (task 1003076, retro on
|
|
3
|
+
// goal 1000071 — the idea-routing seeder linked all 15 tasks to the WRONG
|
|
4
|
+
// criteria via positional "Cn" tokens (learning 1000188) and still printed
|
|
5
|
+
// success; only a manual GET /goals rollup caught it, after the fact). A seed
|
|
6
|
+
// script tallies the criterion_ids it actually POSTed per task into its own
|
|
7
|
+
// plan, then calls verifySeedCriteriaLinks() as its LAST step to fetch
|
|
8
|
+
// GET /api/gds/goals/<id> and diff `criteria[].linked_tasks` against that
|
|
9
|
+
// plan — loud and non-zero-exit on any mismatch, zero included.
|
|
10
|
+
//
|
|
11
|
+
// Transport-agnostic on purpose: seed scripts don't share one HTTP client
|
|
12
|
+
// (some use cli-lib's apiCall, others a local throttled fetch — see the
|
|
13
|
+
// scripts/gds/CLAUDE.md roster), so this takes a `fetchGoal(goalId)` callback
|
|
14
|
+
// rather than assuming a client shape.
|
|
15
|
+
|
|
16
|
+
// diffCriterionLinks — pure comparison: expected (slug -> count, tallied from
|
|
17
|
+
// the criterion_ids actually POSTed) vs actual (the `criteria` rows
|
|
18
|
+
// GET /goals/:id returns, each carrying criterion_id + linked_tasks). Returns
|
|
19
|
+
// every mismatching slug, including one present on only one side.
|
|
20
|
+
function diffCriterionLinks(expectedCounts, criteriaRows) {
|
|
21
|
+
const actual = {};
|
|
22
|
+
for (const row of criteriaRows || []) {
|
|
23
|
+
actual[row.criterion_id] = Number(row.linked_tasks) || 0;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const slugs = new Set([...Object.keys(expectedCounts || {}), ...Object.keys(actual)]);
|
|
27
|
+
const mismatches = [];
|
|
28
|
+
for (const slug of slugs) {
|
|
29
|
+
const expected = (expectedCounts && expectedCounts[slug]) || 0;
|
|
30
|
+
const got = actual[slug] || 0;
|
|
31
|
+
if (expected !== got) mismatches.push({ criterion_id: slug, expected, actual: got });
|
|
32
|
+
}
|
|
33
|
+
return mismatches.sort((a, b) => a.criterion_id.localeCompare(b.criterion_id));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// expectedCriterionCounts — tally a seed script's per-task criterion_ids
|
|
37
|
+
// (already resolved to real slugs, post any "Cn" -> slug mapping) into
|
|
38
|
+
// { slug: count }. Callers pass one array per task so the plan is derived from
|
|
39
|
+
// exactly what got POSTed, not a second hand-maintained total.
|
|
40
|
+
function expectedCriterionCounts(criterionIdListsPerTask) {
|
|
41
|
+
const counts = {};
|
|
42
|
+
for (const ids of criterionIdListsPerTask || []) {
|
|
43
|
+
for (const slug of ids || []) counts[slug] = (counts[slug] || 0) + 1;
|
|
44
|
+
}
|
|
45
|
+
return counts;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// verifySeedCriteriaLinks — Phase-5 self-check: fetch the goal, diff its
|
|
49
|
+
// linked_tasks against the plan, and exit(1) loudly on any mismatch (a zero
|
|
50
|
+
// where tasks were expected is the shape that shipped silently in the
|
|
51
|
+
// RETRO2 incident, but any drift is worth stopping the seed for).
|
|
52
|
+
async function verifySeedCriteriaLinks({ goalId, expectedCounts, fetchGoal, log = console.log, exit = process.exit }) {
|
|
53
|
+
const r = await fetchGoal(goalId);
|
|
54
|
+
const body = (r && (r.json || r.data)) || null;
|
|
55
|
+
if (!r || !r.ok || !body || !Array.isArray(body.criteria)) {
|
|
56
|
+
log(`VERIFY FAILED: GET goal ${goalId} did not return a criteria list (status ${r && r.status}). Cannot confirm criterion links landed.`);
|
|
57
|
+
return exit(1);
|
|
58
|
+
}
|
|
59
|
+
const mismatches = diffCriterionLinks(expectedCounts, body.criteria);
|
|
60
|
+
if (mismatches.length) {
|
|
61
|
+
log(`VERIFY FAILED: criterion links on goal ${goalId} do not match the plan (${mismatches.length} mismatch${mismatches.length === 1 ? '' : 'es'}):`);
|
|
62
|
+
for (const m of mismatches) log(` ${m.criterion_id}: expected ${m.expected} linked task(s), got ${m.actual}`);
|
|
63
|
+
log('Likely cause: a criterion_ids mapping bug (e.g. a positional "Cn" token resolved against the wrong scope — learning 1000188). Fix and re-run before trusting this seed.');
|
|
64
|
+
return exit(1);
|
|
65
|
+
}
|
|
66
|
+
log(`VERIFY OK: all ${Object.keys(expectedCounts || {}).length} criteria on goal ${goalId} carry their planned task count.`);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
module.exports = { diffCriterionLinks, expectedCriterionCounts, verifySeedCriteriaLinks };
|
package/src/module-api.js
CHANGED
|
@@ -45,7 +45,7 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
|
|
|
45
45
|
// there. scripts/gds/bump-version.js still rewrites the literal below; it appends
|
|
46
46
|
// the entry to that file. Look for a version's history there, not here.
|
|
47
47
|
// ---------------------------------------------------------------------------
|
|
48
|
-
const CORE_VERSION = '1.19.
|
|
48
|
+
const CORE_VERSION = '1.19.498'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
49
49
|
|
|
50
50
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
51
51
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
// tests/capture_goal_routing.mjs
|
|
2
|
+
//
|
|
3
|
+
// task 1003070 (R09 of goal 1000071, idea routing) — capture.js gains --goal.
|
|
4
|
+
// Interview decision 4: "CLI capture defaults the goal to the active claim's
|
|
5
|
+
// goal. The suggester still shows alternatives; --goal overrides." This pins
|
|
6
|
+
// the WHOLE of that precedence (parseGoalArg → resolveGoalForCapture) DB-free,
|
|
7
|
+
// the promote_goal_id.mjs way: stub the generated typed client, drive the real
|
|
8
|
+
// exported functions, assert both the outcome and which collaborators ran.
|
|
9
|
+
//
|
|
10
|
+
// Run: node tests/capture_goal_routing.mjs
|
|
11
|
+
|
|
12
|
+
import { strict as assert } from 'node:assert';
|
|
13
|
+
import { createRequire } from 'node:module';
|
|
14
|
+
import { readFileSync } from 'node:fs';
|
|
15
|
+
import { makeRunner } from './helpers.mjs';
|
|
16
|
+
|
|
17
|
+
const require = createRequire(import.meta.url);
|
|
18
|
+
const { test, summary } = makeRunner();
|
|
19
|
+
|
|
20
|
+
const capture = require('../scripts/gds/capture.js');
|
|
21
|
+
const CAPTURE_SRC = readFileSync(new URL('../scripts/gds/capture.js', import.meta.url), 'utf8');
|
|
22
|
+
|
|
23
|
+
// A stub of the generated typed client (triage.js / promote_goal_id.mjs's
|
|
24
|
+
// pattern): records every call, answers from a fixed script. `client` is the
|
|
25
|
+
// injectable collaborator every function under test accepts.
|
|
26
|
+
function stubClient({ me, task, suggestions } = {}) {
|
|
27
|
+
const calls = { me: 0, tasksId: [], suggestions: [] };
|
|
28
|
+
const client = async () => ({
|
|
29
|
+
me: { getMe: async () => { calls.me++; return me; } },
|
|
30
|
+
tasks: { getTasksId: async (req) => { calls.tasksId.push(req); return task; } },
|
|
31
|
+
goals: { getGoalsSuggestions: async (req) => { calls.suggestions.push(req); return suggestions; } },
|
|
32
|
+
});
|
|
33
|
+
return { client, calls };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
// parseGoalArg — pure
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
|
|
40
|
+
await test('parseGoalArg: "none" (any case/whitespace) is the explicit opt-out', () => {
|
|
41
|
+
for (const raw of ['none', 'None', 'NONE', ' none ']) {
|
|
42
|
+
assert.deepEqual(capture.parseGoalArg(raw), { kind: 'none' });
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
await test('parseGoalArg: a positive integer string is an id', () => {
|
|
47
|
+
assert.deepEqual(capture.parseGoalArg('1000071'), { kind: 'id', id: 1000071 });
|
|
48
|
+
assert.deepEqual(capture.parseGoalArg('7'), { kind: 'id', id: 7 });
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
await test('parseGoalArg: absent (null/undefined) is unset, not invalid', () => {
|
|
52
|
+
assert.deepEqual(capture.parseGoalArg(null), { kind: 'unset' });
|
|
53
|
+
assert.deepEqual(capture.parseGoalArg(undefined), { kind: 'unset' });
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
await test('parseGoalArg: junk is invalid, never silently coerced to a goal', () => {
|
|
57
|
+
for (const raw of ['0', '-3', '4.5', 'abc', '', 'goal 7']) {
|
|
58
|
+
assert.deepEqual(capture.parseGoalArg(raw), { kind: 'invalid' }, `"${raw}" must be invalid`);
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
// ---------------------------------------------------------------------------
|
|
63
|
+
// sanitizeForTerminal — strip control bytes (grader finding, task 1003070):
|
|
64
|
+
// a task/goal TITLE reaches the terminal unescaped otherwise.
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
|
|
67
|
+
await test('sanitizeForTerminal: strips C0 control bytes (ANSI escapes among them)', () => {
|
|
68
|
+
assert.equal(capture.sanitizeForTerminal('\x1b[31mred\x1b[0m title'), '[31mred[0m title');
|
|
69
|
+
assert.equal(capture.sanitizeForTerminal('a\tb\nc'), 'abc');
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
await test('sanitizeForTerminal: plain text and null/undefined pass through safely', () => {
|
|
73
|
+
assert.equal(capture.sanitizeForTerminal('ordinary title'), 'ordinary title');
|
|
74
|
+
assert.equal(capture.sanitizeForTerminal(null), '');
|
|
75
|
+
assert.equal(capture.sanitizeForTerminal(undefined), '');
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
await test('WIRING: the printed default/hint lines route the title through sanitizeForTerminal', () => {
|
|
79
|
+
assert.match(CAPTURE_SRC, /sanitizeForTerminal\(defaulted\.taskTitle\)/);
|
|
80
|
+
assert.match(CAPTURE_SRC, /sanitizeForTerminal\(hint\.title\)/);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
// ---------------------------------------------------------------------------
|
|
84
|
+
// resolveActiveClaimGoal — one /me + one GET /tasks/:id
|
|
85
|
+
// ---------------------------------------------------------------------------
|
|
86
|
+
|
|
87
|
+
await test('resolveActiveClaimGoal: reads the goal off the active claim\'s task', async () => {
|
|
88
|
+
const { client } = stubClient({
|
|
89
|
+
me: { ok: true, data: { active_claims: [{ task_id: 55, title: 'Fix the thing', claim_id: 9 }] } },
|
|
90
|
+
task: { ok: true, data: { task: { id: 55, goal_id: 1000071, title: 'Fix the thing' } } },
|
|
91
|
+
});
|
|
92
|
+
const r = await capture.resolveActiveClaimGoal({ client });
|
|
93
|
+
assert.deepEqual(r, { goalId: 1000071, taskId: 55, taskTitle: 'Fix the thing' });
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
await test('resolveActiveClaimGoal: no active claim → null, no task lookup', async () => {
|
|
97
|
+
const { client, calls } = stubClient({ me: { ok: true, data: { active_claims: [] } } });
|
|
98
|
+
const r = await capture.resolveActiveClaimGoal({ client });
|
|
99
|
+
assert.equal(r, null);
|
|
100
|
+
assert.equal(calls.tasksId.length, 0);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
await test('resolveActiveClaimGoal: claimed task has no goal → null', async () => {
|
|
104
|
+
const { client } = stubClient({
|
|
105
|
+
me: { ok: true, data: { active_claim: { task_id: 55, title: 'x' } } },
|
|
106
|
+
task: { ok: true, data: { task: { id: 55, goal_id: null, title: 'x' } } },
|
|
107
|
+
});
|
|
108
|
+
assert.equal(await capture.resolveActiveClaimGoal({ client }), null);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
await test('resolveActiveClaimGoal: a /me outage degrades to null, never throws', async () => {
|
|
112
|
+
const client = async () => ({ me: { getMe: async () => { throw new Error('ECONNREFUSED'); } } });
|
|
113
|
+
assert.equal(await capture.resolveActiveClaimGoal({ client }), null);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
await test('resolveActiveClaimGoal: a non-ok /me degrades to null', async () => {
|
|
117
|
+
const { client } = stubClient({ me: { ok: false, status: 500, data: null } });
|
|
118
|
+
assert.equal(await capture.resolveActiveClaimGoal({ client }), null);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
// ---------------------------------------------------------------------------
|
|
122
|
+
// fetchGoalSuggestion — the R07 suggester, consulted last
|
|
123
|
+
// ---------------------------------------------------------------------------
|
|
124
|
+
|
|
125
|
+
await test('fetchGoalSuggestion: no version → null, no network call at all', async () => {
|
|
126
|
+
const { client, calls } = stubClient({ suggestions: { ok: true, data: { suggestions: [{ id: 1, title: 't', score: 9 }] } } });
|
|
127
|
+
const r = await capture.fetchGoalSuggestion({ client, version: null, title: 't' });
|
|
128
|
+
assert.equal(r, null);
|
|
129
|
+
assert.equal(calls.suggestions.length, 0);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
await test('fetchGoalSuggestion: top suggestion returned', async () => {
|
|
133
|
+
const { client, calls } = stubClient({
|
|
134
|
+
suggestions: { ok: true, data: { suggestions: [{ id: 1000071, title: 'Idea routing', score: 7 }] } },
|
|
135
|
+
});
|
|
136
|
+
const r = await capture.fetchGoalSuggestion({ client, version: 'BV1', title: 'fix the routing', kind: 'bug' });
|
|
137
|
+
assert.deepEqual(r, { id: 1000071, title: 'Idea routing', score: 7 });
|
|
138
|
+
assert.equal(calls.suggestions[0].query.version, 'BV1');
|
|
139
|
+
assert.equal(calls.suggestions[0].query.title, 'fix the routing');
|
|
140
|
+
assert.equal(calls.suggestions[0].query.limit, 1);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
await test('fetchGoalSuggestion: no confident match → null', async () => {
|
|
144
|
+
const { client } = stubClient({ suggestions: { ok: true, data: { suggestions: [] } } });
|
|
145
|
+
assert.equal(await capture.fetchGoalSuggestion({ client, version: 'BV1', title: 't' }), null);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
await test('fetchGoalSuggestion: an outage degrades to null, never throws', async () => {
|
|
149
|
+
const client = async () => ({ goals: { getGoalsSuggestions: async () => { throw new Error('boom'); } } });
|
|
150
|
+
assert.equal(await capture.fetchGoalSuggestion({ client, version: 'BV1', title: 't' }), null);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
// ---------------------------------------------------------------------------
|
|
154
|
+
// resolveGoalForCapture — the whole precedence, as capture.js's main() sees it
|
|
155
|
+
// ---------------------------------------------------------------------------
|
|
156
|
+
|
|
157
|
+
await test('--goal N: sends that goal id verbatim; no active-claim or suggester lookup', async () => {
|
|
158
|
+
const { client, calls } = stubClient({
|
|
159
|
+
me: { ok: true, data: { active_claims: [{ task_id: 1, title: 'x' }] } },
|
|
160
|
+
task: { ok: true, data: { task: { id: 1, goal_id: 999 } } },
|
|
161
|
+
suggestions: { ok: true, data: { suggestions: [{ id: 5, title: 'y', score: 1 }] } },
|
|
162
|
+
});
|
|
163
|
+
const r = await capture.resolveGoalForCapture({ goalArg: '42', version: 'BV1', title: 't', client });
|
|
164
|
+
assert.deepEqual(r, { goalId: 42, lines: [] });
|
|
165
|
+
assert.equal(calls.me, 0, 'the active-claim default must not even be consulted');
|
|
166
|
+
assert.equal(calls.suggestions.length, 0, 'the suggester must not even be consulted');
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
await test('--goal none: sends no goal_id and skips the active-claim default entirely', async () => {
|
|
170
|
+
const { client, calls } = stubClient({
|
|
171
|
+
me: { ok: true, data: { active_claims: [{ task_id: 1, title: 'x' }] } },
|
|
172
|
+
task: { ok: true, data: { task: { id: 1, goal_id: 999 } } },
|
|
173
|
+
});
|
|
174
|
+
const r = await capture.resolveGoalForCapture({ goalArg: 'none', version: 'BV1', title: 't', client });
|
|
175
|
+
assert.deepEqual(r, { goalId: null, lines: [] });
|
|
176
|
+
assert.equal(calls.me, 0, '--goal none is a hard opt-out — it must not look up the active claim');
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
await test('no --goal: the active-claim default fires and names how to opt out', async () => {
|
|
180
|
+
const { client, calls } = stubClient({
|
|
181
|
+
me: { ok: true, data: { active_claims: [{ task_id: 55, title: 'Fix the thing' }] } },
|
|
182
|
+
task: { ok: true, data: { task: { id: 55, goal_id: 1000071, title: 'Fix the thing' } } },
|
|
183
|
+
});
|
|
184
|
+
const r = await capture.resolveGoalForCapture({ goalArg: null, version: 'BV1', title: 't', client });
|
|
185
|
+
assert.equal(r.goalId, 1000071);
|
|
186
|
+
assert.equal(r.lines.length, 1);
|
|
187
|
+
assert.match(r.lines[0], /1000071/);
|
|
188
|
+
assert.match(r.lines[0], /#55/);
|
|
189
|
+
assert.match(r.lines[0], /--goal none/, 'must say how to opt out');
|
|
190
|
+
assert.equal(calls.suggestions.length, 0, 'a resolved default skips the suggester');
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
await test('no --goal, no active claim: the suggester hint prints, with the re-run command', async () => {
|
|
194
|
+
const { client } = stubClient({
|
|
195
|
+
me: { ok: true, data: { active_claims: [] } },
|
|
196
|
+
suggestions: { ok: true, data: { suggestions: [{ id: 1000058, title: 'Some other goal', score: 5 }] } },
|
|
197
|
+
});
|
|
198
|
+
const r = await capture.resolveGoalForCapture({ goalArg: null, version: 'BV1', title: 't', client });
|
|
199
|
+
assert.equal(r.goalId, null, 'a hint is an offer, never an auto-pick');
|
|
200
|
+
assert.equal(r.lines.length, 1);
|
|
201
|
+
assert.match(r.lines[0], /1000058/);
|
|
202
|
+
assert.match(r.lines[0], /--goal 1000058/, 'must give the exact re-run command');
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
await test('no --goal, no active claim, no confident suggestion: silent — no lines', async () => {
|
|
206
|
+
const { client } = stubClient({
|
|
207
|
+
me: { ok: true, data: { active_claims: [] } },
|
|
208
|
+
suggestions: { ok: true, data: { suggestions: [] } },
|
|
209
|
+
});
|
|
210
|
+
const r = await capture.resolveGoalForCapture({ goalArg: null, version: 'BV1', title: 't', client });
|
|
211
|
+
assert.deepEqual(r, { goalId: null, lines: [] });
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
await test('active claim exists but its task has no goal: falls through to the suggester', async () => {
|
|
215
|
+
const { client, calls } = stubClient({
|
|
216
|
+
me: { ok: true, data: { active_claims: [{ task_id: 55, title: 'x' }] } },
|
|
217
|
+
task: { ok: true, data: { task: { id: 55, goal_id: null, title: 'x' } } },
|
|
218
|
+
suggestions: { ok: true, data: { suggestions: [{ id: 3, title: 'z', score: 2 }] } },
|
|
219
|
+
});
|
|
220
|
+
const r = await capture.resolveGoalForCapture({ goalArg: null, version: 'BV1', title: 't', client });
|
|
221
|
+
assert.equal(r.goalId, null);
|
|
222
|
+
assert.equal(calls.suggestions.length, 1, 'a goal-less claimed task must still fall through to the hint');
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
// ---------------------------------------------------------------------------
|
|
226
|
+
// STATIC — main()'s wiring a unit test cannot invoke directly (argv, TTY, fs).
|
|
227
|
+
// ---------------------------------------------------------------------------
|
|
228
|
+
|
|
229
|
+
await test('WIRING: main() sends resolveGoalForCapture\'s id on the capture payload, only when resolved', () => {
|
|
230
|
+
assert.match(CAPTURE_SRC, /if \(goalResolution\.goalId != null\) payload\.goal_id = goalResolution\.goalId;/);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
await test('WIRING: main() reads routed_task off the response and reports where it landed', () => {
|
|
234
|
+
assert.match(CAPTURE_SRC, /const \{ idea, open_count, kind_auto, routed_task \} = r\.data;/);
|
|
235
|
+
assert.match(CAPTURE_SRC, /routed into goal/);
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
await test('WIRING: a routing refusal is surfaced through the existing verbatim error path, not re-derived', () => {
|
|
239
|
+
assert.match(CAPTURE_SRC, /console\.error\(formatCaptureFailure\(r\.status, r\.data\)\);/);
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
summary();
|