@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.
@@ -40,8 +40,14 @@ function generalGoalTitle(versionId) {
40
40
  // authorization gate. This is advice appended to a request that ALREADY SUCCEEDED;
41
41
  // a wrong guess here nags a builder about a task that is fine, which is worse than
42
42
  // staying quiet. A missing versionId or an unreadable goal title yields null.
43
- function adviseGoal({ versionId, goalId, goalTitle } = {}) {
43
+ //
44
+ // kind='bug' is a THIRD silent case (task 1003214, idea 1000828 — the owner
45
+ // directive against manufacturing small goals): a fix sitting in the catch-all,
46
+ // or with no goal at all, is already where it belongs, so both reasons above
47
+ // collapse to null for a bug. The nudge is for homeless FEATURE work only.
48
+ function adviseGoal({ versionId, goalId, goalTitle, kind } = {}) {
44
49
  if (goalId == null) {
50
+ if (kind === 'bug') return null;
45
51
  return {
46
52
  reason: 'no_goal',
47
53
  message: 'This task has no goal to live in — no scope wall, no done-when criteria gating it, '
@@ -53,6 +59,7 @@ function adviseGoal({ versionId, goalId, goalTitle } = {}) {
53
59
  }
54
60
  if (versionId == null || typeof goalTitle !== 'string') return null;
55
61
  if (goalTitle !== generalGoalTitle(versionId)) return null;
62
+ if (kind === 'bug') return null;
56
63
  return {
57
64
  reason: 'catch_all',
58
65
  message: `This task landed in "${goalTitle}", the version's catch-all bucket rather than a real goal — `
@@ -93,6 +100,7 @@ async function buildGoalAdvisory({ db, versionId, task }) {
93
100
  versionId,
94
101
  goalId: task.goal_id,
95
102
  goalTitle: homeGoal && homeGoal.title,
103
+ kind: task.kind,
96
104
  });
97
105
  if (!verdict) return null;
98
106
  // LIMIT 6, not 6-in-JS: Postgres truncates instead of shipping every open goal
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.495",
3
+ "version": "1.19.497",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.495",
9
+ "version": "1.19.497",
10
10
  "license": "AGPL-3.0-or-later",
11
11
  "dependencies": {
12
12
  "express": "^4.21.2",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.495",
3
+ "version": "1.19.497",
4
4
  "description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "main": "src/platform-server.js",
@@ -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 = { resolveDefaultVersion, formatCaptureFailure, acceptsOffer };
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: t.crit.map((c) => CRIT[c]), source: SRC, source_ref: `bongos-v1-idea-routing-${t.ref}`,
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.495'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
48
+ const CORE_VERSION = '1.19.497'; // 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();