@bongos/core 1.19.288 → 1.19.289
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 +29 -19
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +2 -0
- package/clients/bongos-client/index.cjs +2 -0
- package/clients/bongos-client/index.d.ts +3 -2
- package/clients/bongos-client/index.mjs +2 -0
- package/docs/api/openapi.json +35 -22
- package/docs/api-reference.md +3 -2
- package/modules/lifecycle/goal-suggest.js +171 -0
- package/modules/lifecycle/routes/goals.js +9 -19
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/src/bongos/route-rank-check.js +17 -2
- package/src/module-api.js +3 -1
- package/tests/goal_suggest.mjs +195 -0
package/.bongos-core.json
CHANGED
|
@@ -2,22 +2,22 @@
|
|
|
2
2
|
"artifact": "bongos-core",
|
|
3
3
|
"manifest_schema": 1,
|
|
4
4
|
"generator": "scripts/gds/package-core.js",
|
|
5
|
-
"core_version": "1.19.
|
|
6
|
-
"core_contract": "1.19.
|
|
7
|
-
"source_commit": "
|
|
5
|
+
"core_version": "1.19.289",
|
|
6
|
+
"core_contract": "1.19.289",
|
|
7
|
+
"source_commit": "7fcb844542a0043f5c5beff9a14978d08dba62a9",
|
|
8
8
|
"source_ref": "HEAD",
|
|
9
|
-
"built_at": "2026-08-
|
|
9
|
+
"built_at": "2026-08-22T03:47:31.708Z",
|
|
10
10
|
"redaction": {
|
|
11
11
|
"model": "docs-redacted+functional-verbatim",
|
|
12
12
|
"docs_redacted": 320,
|
|
13
13
|
"agent_docs_stubbed": 22,
|
|
14
|
-
"functional_verbatim":
|
|
14
|
+
"functional_verbatim": 1388,
|
|
15
15
|
"rules": 3,
|
|
16
16
|
"gate_literals": 3,
|
|
17
17
|
"gate": "passed"
|
|
18
18
|
},
|
|
19
|
-
"file_count":
|
|
20
|
-
"tree_sha256": "
|
|
19
|
+
"file_count": 1730,
|
|
20
|
+
"tree_sha256": "08c303ce54af46b2a131d468064b1ec877f3120d3d6df123a8a5e4c88be18af6",
|
|
21
21
|
"files": [
|
|
22
22
|
{
|
|
23
23
|
"path": ".claude/skills/blocker-review/SKILL.md",
|
|
@@ -347,12 +347,12 @@
|
|
|
347
347
|
{
|
|
348
348
|
"path": "clients/bongos-client/README.md",
|
|
349
349
|
"mode": "0000644",
|
|
350
|
-
"sha256": "
|
|
350
|
+
"sha256": "aaf0bb7dae10d271e9999093935fef5ae8d85c3a2e3d5b4c52e0c11fed8c581e"
|
|
351
351
|
},
|
|
352
352
|
{
|
|
353
353
|
"path": "clients/bongos-client/bongos-client.global.js",
|
|
354
354
|
"mode": "0000644",
|
|
355
|
-
"sha256": "
|
|
355
|
+
"sha256": "372a5584ddad52fb524815fc7b50d1aab9163e974aac6f2c64f479e4034914a1"
|
|
356
356
|
},
|
|
357
357
|
{
|
|
358
358
|
"path": "clients/bongos-client/examples/hello-world.mjs",
|
|
@@ -362,17 +362,17 @@
|
|
|
362
362
|
{
|
|
363
363
|
"path": "clients/bongos-client/index.cjs",
|
|
364
364
|
"mode": "0000644",
|
|
365
|
-
"sha256": "
|
|
365
|
+
"sha256": "7faac7fc48e714c76c2b19feaaf9c730b2c9b10ab71ddc5697c21497d02f10ef"
|
|
366
366
|
},
|
|
367
367
|
{
|
|
368
368
|
"path": "clients/bongos-client/index.d.ts",
|
|
369
369
|
"mode": "0000644",
|
|
370
|
-
"sha256": "
|
|
370
|
+
"sha256": "7aba0fe1d782cb6308f57dc2e5f6fe0d5ffe0f537d4ffe846bdc7a03e0f8064d"
|
|
371
371
|
},
|
|
372
372
|
{
|
|
373
373
|
"path": "clients/bongos-client/index.mjs",
|
|
374
374
|
"mode": "0000644",
|
|
375
|
-
"sha256": "
|
|
375
|
+
"sha256": "5f1d221bfbf8d329f883a5dc3e84414728c67201d66faceff04d48732e44b13c"
|
|
376
376
|
},
|
|
377
377
|
{
|
|
378
378
|
"path": "clients/bongos-client/package.json",
|
|
@@ -1407,12 +1407,12 @@
|
|
|
1407
1407
|
{
|
|
1408
1408
|
"path": "docs/api-reference.md",
|
|
1409
1409
|
"mode": "0000644",
|
|
1410
|
-
"sha256": "
|
|
1410
|
+
"sha256": "2c4a9b56aea5a8e1a42c70ad5c2646c0d6db6d713c13b8d2068bd3a2fa6ac27e"
|
|
1411
1411
|
},
|
|
1412
1412
|
{
|
|
1413
1413
|
"path": "docs/api/openapi.json",
|
|
1414
1414
|
"mode": "0000644",
|
|
1415
|
-
"sha256": "
|
|
1415
|
+
"sha256": "fcb40a7d655517e6dd24b2d4e0e00a9fcace75b2eb0aa89348d2546abf8df422"
|
|
1416
1416
|
},
|
|
1417
1417
|
{
|
|
1418
1418
|
"path": "docs/architecture.md",
|
|
@@ -3814,6 +3814,11 @@
|
|
|
3814
3814
|
"mode": "0000644",
|
|
3815
3815
|
"sha256": "0367f88aefa890d8d7f2472dd847467d632150a93db8a96b85717b5fb1cc8c9e"
|
|
3816
3816
|
},
|
|
3817
|
+
{
|
|
3818
|
+
"path": "modules/lifecycle/goal-suggest.js",
|
|
3819
|
+
"mode": "0000644",
|
|
3820
|
+
"sha256": "5fac15e501d6e5639b9e14d08ab34723b60e3569af913f4d15bb703a4e126a77"
|
|
3821
|
+
},
|
|
3817
3822
|
{
|
|
3818
3823
|
"path": "modules/lifecycle/hierarchy.js",
|
|
3819
3824
|
"mode": "0000644",
|
|
@@ -3882,7 +3887,7 @@
|
|
|
3882
3887
|
{
|
|
3883
3888
|
"path": "modules/lifecycle/routes/goals.js",
|
|
3884
3889
|
"mode": "0000644",
|
|
3885
|
-
"sha256": "
|
|
3890
|
+
"sha256": "3fcc0339927bcc7f426e93c7a5762b45496ecf4e9b841f905fb5f1c468b5f8c0"
|
|
3886
3891
|
},
|
|
3887
3892
|
{
|
|
3888
3893
|
"path": "modules/lifecycle/routes/lifecycle.js",
|
|
@@ -4517,12 +4522,12 @@
|
|
|
4517
4522
|
{
|
|
4518
4523
|
"path": "package-lock.json",
|
|
4519
4524
|
"mode": "0000644",
|
|
4520
|
-
"sha256": "
|
|
4525
|
+
"sha256": "4a910e78786de976213abedacfe08553775d9651b46579e1388941d416be82db"
|
|
4521
4526
|
},
|
|
4522
4527
|
{
|
|
4523
4528
|
"path": "package.json",
|
|
4524
4529
|
"mode": "0000644",
|
|
4525
|
-
"sha256": "
|
|
4530
|
+
"sha256": "9919d125bfcfaccf90bc06727eeec3f410882a6e1c83481e63f79f7a5d2d7a0b"
|
|
4526
4531
|
},
|
|
4527
4532
|
{
|
|
4528
4533
|
"path": "public-docs/index.html",
|
|
@@ -6032,7 +6037,7 @@
|
|
|
6032
6037
|
{
|
|
6033
6038
|
"path": "src/bongos/route-rank-check.js",
|
|
6034
6039
|
"mode": "0000644",
|
|
6035
|
-
"sha256": "
|
|
6040
|
+
"sha256": "97b8345a1d891151c3bc134926124a0ac929db661e0cfb26c6760c8c6030bafc"
|
|
6036
6041
|
},
|
|
6037
6042
|
{
|
|
6038
6043
|
"path": "src/bongos/routes.js",
|
|
@@ -6167,7 +6172,7 @@
|
|
|
6167
6172
|
{
|
|
6168
6173
|
"path": "src/module-api.js",
|
|
6169
6174
|
"mode": "0000644",
|
|
6170
|
-
"sha256": "
|
|
6175
|
+
"sha256": "fbb190fb4b0b50ef8374412a009f1a4799ecbdb15b37caabac3c8a138ca29622"
|
|
6171
6176
|
},
|
|
6172
6177
|
{
|
|
6173
6178
|
"path": "src/module-loader/catalog.js",
|
|
@@ -7129,6 +7134,11 @@
|
|
|
7129
7134
|
"mode": "0000644",
|
|
7130
7135
|
"sha256": "447e6376ae5f5e2bedf1a80b3355604544a5e349da87d110fc0135cc3cd98fac"
|
|
7131
7136
|
},
|
|
7137
|
+
{
|
|
7138
|
+
"path": "tests/goal_suggest.mjs",
|
|
7139
|
+
"mode": "0000644",
|
|
7140
|
+
"sha256": "7fc1d81afc49311a3c8525b644319e4831bff295bef8be244220fea5ebfbbbd1"
|
|
7141
|
+
},
|
|
7132
7142
|
{
|
|
7133
7143
|
"path": "tests/goal_tier.mjs",
|
|
7134
7144
|
"mode": "0000644",
|
|
@@ -5,7 +5,7 @@ A **generated**, zero-dependency typed client for the Bongos API — produced fr
|
|
|
5
5
|
by hand; it regenerates when the spec changes, so it can never drift from the routes.
|
|
6
6
|
|
|
7
7
|
- API version: **v1** (served at `/api/bongos/v1`)
|
|
8
|
-
-
|
|
8
|
+
- 294 operations across 47 resource groups
|
|
9
9
|
|
|
10
10
|
## Install
|
|
11
11
|
|
|
@@ -355,6 +355,8 @@ function createClient(opts = {}) {
|
|
|
355
355
|
getGoalsInbox: (args) => request("GET", "/goals/inbox", { hasBody: false }, args),
|
|
356
356
|
// POST /goals/reorder — rank: metic+archon — POST /goals/reorder
|
|
357
357
|
postGoalsReorder: (args) => request("POST", "/goals/reorder", { hasBody: true }, args),
|
|
358
|
+
// GET /goals/suggestions — rank: any-builder — GET /goals/suggestions
|
|
359
|
+
getGoalsSuggestions: (args) => request("GET", "/goals/suggestions", { hasBody: false }, args),
|
|
358
360
|
},
|
|
359
361
|
"government": {
|
|
360
362
|
// GET /government/assignments — rank: archon — GET /government/assignments
|
|
@@ -354,6 +354,8 @@ function createClient(opts = {}) {
|
|
|
354
354
|
getGoalsInbox: (args) => request("GET", "/goals/inbox", { hasBody: false }, args),
|
|
355
355
|
// POST /goals/reorder — rank: metic+archon — POST /goals/reorder
|
|
356
356
|
postGoalsReorder: (args) => request("POST", "/goals/reorder", { hasBody: true }, args),
|
|
357
|
+
// GET /goals/suggestions — rank: any-builder — GET /goals/suggestions
|
|
358
|
+
getGoalsSuggestions: (args) => request("GET", "/goals/suggestions", { hasBody: false }, args),
|
|
357
359
|
},
|
|
358
360
|
"government": {
|
|
359
361
|
// GET /government/assignments — rank: archon — GET /government/assignments
|
|
@@ -40,7 +40,6 @@ export interface GetGithubReposResponse { repos: unknown }
|
|
|
40
40
|
export interface GetGoalsIdConflictsResponse { ok: boolean; goal_id: unknown; conflicts: unknown; cross_goals_considered: unknown }
|
|
41
41
|
export interface GetGoalsIdRequestsResponse { requests: unknown }
|
|
42
42
|
export interface GetGoalsIdResponse { goal: unknown; members: unknown; criteria: unknown; dependencies: unknown }
|
|
43
|
-
export interface GetGoalsIdSuggestCriteriaResponse { goal_id: unknown; suggestions: unknown }
|
|
44
43
|
export interface GetGoalsResponse { goals: unknown }
|
|
45
44
|
export interface GetGovernmentAssignmentsResponse { assignments: unknown }
|
|
46
45
|
export interface GetGovernmentPermissionsResponse { permissions: unknown; principals: unknown }
|
|
@@ -605,7 +604,7 @@ export interface BongosClient {
|
|
|
605
604
|
/** POST /goals/{id}/scope — rank: metic+archon */
|
|
606
605
|
postGoalsIdScope(args: RequestArgs & { body: PostGoalsIdScopeRequest }): Promise<PostGoalsIdScopeResponse>;
|
|
607
606
|
/** GET /goals/{id}/suggest-criteria — rank: any-builder */
|
|
608
|
-
getGoalsIdSuggestCriteria(args?: RequestArgs): Promise<
|
|
607
|
+
getGoalsIdSuggestCriteria(args?: RequestArgs): Promise<ApiResponse>;
|
|
609
608
|
/** POST /goals/{id}/tasks — rank: any-builder */
|
|
610
609
|
postGoalsIdTasks(args: RequestArgs & { body: PostGoalsIdTasksRequest }): Promise<PostGoalsIdTasksResponse>;
|
|
611
610
|
/** POST /goals/{id}/transfer — rank: any-builder */
|
|
@@ -616,6 +615,8 @@ export interface BongosClient {
|
|
|
616
615
|
getGoalsInbox(args?: RequestArgs): Promise<ApiResponse>;
|
|
617
616
|
/** POST /goals/reorder — rank: metic+archon */
|
|
618
617
|
postGoalsReorder(args: RequestArgs & { body: PostGoalsReorderRequest }): Promise<PostGoalsReorderResponse>;
|
|
618
|
+
/** GET /goals/suggestions — rank: any-builder */
|
|
619
|
+
getGoalsSuggestions(args?: RequestArgs): Promise<ApiResponse>;
|
|
619
620
|
};
|
|
620
621
|
"government": {
|
|
621
622
|
/** GET /government/assignments — rank: archon */
|
|
@@ -351,6 +351,8 @@ export function createClient(opts = {}) {
|
|
|
351
351
|
getGoalsInbox: (args) => request("GET", "/goals/inbox", { hasBody: false }, args),
|
|
352
352
|
// POST /goals/reorder — rank: metic+archon — POST /goals/reorder
|
|
353
353
|
postGoalsReorder: (args) => request("POST", "/goals/reorder", { hasBody: true }, args),
|
|
354
|
+
// GET /goals/suggestions — rank: any-builder — GET /goals/suggestions
|
|
355
|
+
getGoalsSuggestions: (args) => request("GET", "/goals/suggestions", { hasBody: false }, args),
|
|
354
356
|
},
|
|
355
357
|
"government": {
|
|
356
358
|
// GET /government/assignments — rank: archon — GET /government/assignments
|
package/docs/api/openapi.json
CHANGED
|
@@ -4228,6 +4228,37 @@
|
|
|
4228
4228
|
]
|
|
4229
4229
|
}
|
|
4230
4230
|
},
|
|
4231
|
+
"/goals/suggestions": {
|
|
4232
|
+
"get": {
|
|
4233
|
+
"operationId": "get_goals_suggestions",
|
|
4234
|
+
"tags": [
|
|
4235
|
+
"goals"
|
|
4236
|
+
],
|
|
4237
|
+
"summary": "GET /goals/suggestions",
|
|
4238
|
+
"description": "GET /goals/suggestions — \"which goal should this draft live in?\", answered before anything is created (task 1003062 / R07). Handler + the ranking and visibility rationale: ../goal-suggest.js. Mounted BEFORE GET /goals/:id so 'suggestions' is never captured as an id. rank: any-builder — a read of open goal titles, same surface as GET /goals.\n\n**Rank:** `any-builder` — Any authenticated builder (row-level ownership enforced in-handler).",
|
|
4239
|
+
"x-rank": "any-builder",
|
|
4240
|
+
"x-source": "modules/lifecycle/routes/goals.js",
|
|
4241
|
+
"responses": {
|
|
4242
|
+
"200": {
|
|
4243
|
+
"description": "Success."
|
|
4244
|
+
},
|
|
4245
|
+
"400": {
|
|
4246
|
+
"$ref": "#/components/responses/BadRequest"
|
|
4247
|
+
},
|
|
4248
|
+
"401": {
|
|
4249
|
+
"$ref": "#/components/responses/Unauthorized"
|
|
4250
|
+
},
|
|
4251
|
+
"403": {
|
|
4252
|
+
"$ref": "#/components/responses/Forbidden"
|
|
4253
|
+
}
|
|
4254
|
+
},
|
|
4255
|
+
"security": [
|
|
4256
|
+
{
|
|
4257
|
+
"builderSession": []
|
|
4258
|
+
}
|
|
4259
|
+
]
|
|
4260
|
+
}
|
|
4261
|
+
},
|
|
4231
4262
|
"/goals/{id}": {
|
|
4232
4263
|
"get": {
|
|
4233
4264
|
"operationId": "get_goals_id",
|
|
@@ -5257,14 +5288,7 @@
|
|
|
5257
5288
|
],
|
|
5258
5289
|
"responses": {
|
|
5259
5290
|
"200": {
|
|
5260
|
-
"description": "Success."
|
|
5261
|
-
"content": {
|
|
5262
|
-
"application/json": {
|
|
5263
|
-
"schema": {
|
|
5264
|
-
"$ref": "#/components/schemas/GetGoalsIdSuggestCriteriaResponse"
|
|
5265
|
-
}
|
|
5266
|
-
}
|
|
5267
|
-
}
|
|
5291
|
+
"description": "Success."
|
|
5268
5292
|
},
|
|
5269
5293
|
"400": {
|
|
5270
5294
|
"$ref": "#/components/responses/BadRequest"
|
|
@@ -13780,17 +13804,6 @@
|
|
|
13780
13804
|
"dependencies"
|
|
13781
13805
|
]
|
|
13782
13806
|
},
|
|
13783
|
-
"GetGoalsIdSuggestCriteriaResponse": {
|
|
13784
|
-
"type": "object",
|
|
13785
|
-
"properties": {
|
|
13786
|
-
"goal_id": {},
|
|
13787
|
-
"suggestions": {}
|
|
13788
|
-
},
|
|
13789
|
-
"required": [
|
|
13790
|
-
"goal_id",
|
|
13791
|
-
"suggestions"
|
|
13792
|
-
]
|
|
13793
|
-
},
|
|
13794
13807
|
"GetGoalsResponse": {
|
|
13795
13808
|
"type": "object",
|
|
13796
13809
|
"properties": {
|
|
@@ -18546,9 +18559,9 @@
|
|
|
18546
18559
|
"description": "A required dependency/feature is not configured or is temporarily down."
|
|
18547
18560
|
}
|
|
18548
18561
|
},
|
|
18549
|
-
"x-endpoint-count":
|
|
18550
|
-
"x-schema-count":
|
|
18562
|
+
"x-endpoint-count": 294,
|
|
18563
|
+
"x-schema-count": 327,
|
|
18551
18564
|
"x-undocumented-bodies": 8,
|
|
18552
|
-
"x-response-schemas":
|
|
18565
|
+
"x-response-schemas": 218,
|
|
18553
18566
|
"x-generated-by": "scripts/gds/gen-api-docs.js"
|
|
18554
18567
|
}
|
package/docs/api-reference.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
# Bongos API reference
|
|
4
4
|
|
|
5
|
-
> **Generated from the live route files** — the route file is authoritative.
|
|
5
|
+
> **Generated from the live route files** — the route file is authoritative. 294 endpoints across 47 route files.
|
|
6
6
|
> Machine-readable spec: [`docs/api/openapi.json`](api/openapi.json) (OpenAPI 3.1). Rendered docs site: **`/docs`** (e.g. `cloudbongos.com/docs`).
|
|
7
7
|
|
|
8
8
|
Base path: `/api/bongos`. Ranks (enforced server-side, [ADR 0016](adr/<redacted>.md)): `public` < `any-builder` < `metic+archon` < `archon`.
|
|
@@ -197,7 +197,7 @@ Base path: `/api/bongos`. Ranks (enforced server-side, [ADR 0016](adr/<redacted>
|
|
|
197
197
|
| GET | `/api/bongos/github/repos` | `any-builder` | — | acts only on the caller's OWN GitHub identity (the token they granted). |
|
|
198
198
|
| POST | `/api/bongos/github/repos` | `any-builder` | `name` | creates a repo in the caller's OWN account. |
|
|
199
199
|
|
|
200
|
-
## `goals` (
|
|
200
|
+
## `goals` (26)
|
|
201
201
|
|
|
202
202
|
| Method | Path | Rank | Body | Description |
|
|
203
203
|
|---|---|---|---|---|
|
|
@@ -226,6 +226,7 @@ Base path: `/api/bongos`. Ranks (enforced server-side, [ADR 0016](adr/<redacted>
|
|
|
226
226
|
| PATCH | `/api/bongos/goals/:id/visibility` | `any-builder` | — | PATCH /goals/:id/visibility — make a goal public\|private (task 1952 / ADR 0112). |
|
|
227
227
|
| GET | `/api/bongos/goals/inbox` | `any-builder` | — | GET /goals/inbox — the caller's private-goals inbox (task 1952 / ADR 0112): { invitations: invites addressed to me (accept/decline); join… |
|
|
228
228
|
| POST | `/api/bongos/goals/reorder` | `metic+archon` | `version_id`, `ordered_ids` | POST /goals/reorder — Archon drag-to-reorder: set the priority order of every goal WITHIN one version (task 1760). |
|
|
229
|
+
| GET | `/api/bongos/goals/suggestions` | `any-builder` | — | GET /goals/suggestions — "which goal should this draft live in?", answered before anything is created (task 1003062 / R07). |
|
|
229
230
|
|
|
230
231
|
## `government` (13)
|
|
231
232
|
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// modules/lifecycle/goal-suggest.js — "which goal should this work live in?",
|
|
4
|
+
// answered BEFORE anything is created (task 1003062 / R07 of goal 1000071).
|
|
5
|
+
//
|
|
6
|
+
// THE GAP. `buildGoalAdvisory` (goal-advisory.js) already answers this — but only
|
|
7
|
+
// AFTER a task exists, as a hint attached to the 201. Every consumer R07 feeds
|
|
8
|
+
// needs it BEFORE: the hall filing surface offering a goal picker (R08), the CLI
|
|
9
|
+
// asking at capture time (R09), and the Discord suggestion (R11) all have a draft
|
|
10
|
+
// title and no row yet. So the candidate logic is lifted into a pure read that
|
|
11
|
+
// creates nothing.
|
|
12
|
+
//
|
|
13
|
+
// WHY THIS RANKS, rather than returning "the first five open goals". Unranked
|
|
14
|
+
// candidates are useless here: BONGOS-V1 carries dozens of open goals, so an
|
|
15
|
+
// arbitrary five is noise dressed as a suggestion. Ranking is what makes it a
|
|
16
|
+
// suggester. It reuses `tokenize` from criterion-suggest.js — a sibling, already
|
|
17
|
+
// covering the weighting idea (title terms 2, body terms 1) and already tested —
|
|
18
|
+
// so there is one notion of "these two texts are about the same thing" in the
|
|
19
|
+
// module, not two.
|
|
20
|
+
//
|
|
21
|
+
// WHAT A GOAL IS MATCHED ON: its title and its scope_modules. Deliberately NOT
|
|
22
|
+
// its description — a goal description is where a whole planning spec lives (goal
|
|
23
|
+
// 1000062 carries an entire decision record in its), so it shares tokens with
|
|
24
|
+
// almost any draft and would flatten every score toward the same number. Modules
|
|
25
|
+
// are included because they are a strong, cheap signal: a draft about the hall
|
|
26
|
+
// really does belong with a goal scoped to `hall-ui`.
|
|
27
|
+
|
|
28
|
+
const { tokenize } = require('./criterion-suggest.js');
|
|
29
|
+
const { candidateGoals } = require('./goal-advisory.js');
|
|
30
|
+
const api = require('../../src/module-api');
|
|
31
|
+
// Through the published logger seam, not a raw console call (est-advisory.js precedent).
|
|
32
|
+
const proseErr = (err) => api.logger('lifecycle').error({ err: err && err.message }, 'GET /goals/suggestions failed');
|
|
33
|
+
|
|
34
|
+
// Matches suggestCriteria's default so the two suggesters feel the same.
|
|
35
|
+
const SUGGEST_LIMIT = 5;
|
|
36
|
+
|
|
37
|
+
// Rank open goals against a DRAFT capture. Pure — no DB, no writes; the caller
|
|
38
|
+
// supplies the goal rows.
|
|
39
|
+
//
|
|
40
|
+
// goals — open goal rows { id, title, scope_modules }
|
|
41
|
+
// draft — { title, body } (body optional: a capture's prose, weighted lower)
|
|
42
|
+
// opts — { versionId, limit, includeZero }
|
|
43
|
+
//
|
|
44
|
+
// Returns [{ id, title, scope_modules, score, matched_terms }] best-first, ties
|
|
45
|
+
// broken by ascending id so output is stable for a picker.
|
|
46
|
+
//
|
|
47
|
+
// `includeZero` defaults FALSE, following suggestCriteria: a draft that matches
|
|
48
|
+
// nothing returns [] — "no confident suggestion" — rather than five arbitrary
|
|
49
|
+
// goals. The route reports the open-goal count alongside so a caller can still
|
|
50
|
+
// offer "browse all" without this function pretending to a confidence it lacks.
|
|
51
|
+
function suggestGoals(goals, draft = {}, opts = {}) {
|
|
52
|
+
const { title = '', body = '' } = draft;
|
|
53
|
+
const { versionId = null, limit = SUGGEST_LIMIT, includeZero = false } = opts;
|
|
54
|
+
|
|
55
|
+
// Weighted term map: body terms at 1, title terms override to 2 (a term in
|
|
56
|
+
// both IS a title term — the stronger signal wins). suggestCriteria's rule.
|
|
57
|
+
const weights = new Map();
|
|
58
|
+
for (const t of tokenize(body)) weights.set(t, 1);
|
|
59
|
+
for (const t of tokenize(title)) weights.set(t, 2);
|
|
60
|
+
|
|
61
|
+
// candidateGoals drops the "<version> — general" catch-all and normalises the
|
|
62
|
+
// row shape. Reused rather than re-filtered so "which goals may be suggested"
|
|
63
|
+
// has one definition — the catch-all is never a suggestion, it is the fallback
|
|
64
|
+
// when there is no home, which is a different question (R03 owns that).
|
|
65
|
+
const ranked = candidateGoals(goals, versionId).map((g) => {
|
|
66
|
+
const goalTerms = new Set([
|
|
67
|
+
...tokenize(g.title),
|
|
68
|
+
// A module key like `hall-ui` tokenizes to ['hall','ui']; 'ui' is dropped
|
|
69
|
+
// by the >=3-char filter, which is fine — 'hall' is the signal.
|
|
70
|
+
...tokenize((g.scope_modules || []).join(' ')),
|
|
71
|
+
]);
|
|
72
|
+
let score = 0;
|
|
73
|
+
const matched = [];
|
|
74
|
+
for (const [term, w] of weights) {
|
|
75
|
+
if (goalTerms.has(term)) { score += w; matched.push(term); }
|
|
76
|
+
}
|
|
77
|
+
return { ...g, score, matched_terms: matched.sort() };
|
|
78
|
+
}).sort((a, b) => (b.score - a.score) || (Number(a.id) - Number(b.id)));
|
|
79
|
+
|
|
80
|
+
const kept = includeZero ? ranked : ranked.filter((r) => r.score > 0);
|
|
81
|
+
return kept.slice(0, Math.max(0, limit));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
// goalSuggestHandler — GET /goals/suggestions. Handler lives here, not inline in
|
|
86
|
+
// routes/goals.js, because that file is over the fitness oversize threshold and
|
|
87
|
+
// the ratchet is shrink-only; collaborators are injected so this module still
|
|
88
|
+
// requires nothing but its siblings.
|
|
89
|
+
//
|
|
90
|
+
// Creates NOTHING — that is the point of R07. A caller with a draft title gets
|
|
91
|
+
// candidates and decides; POST /inbox (R01) is what actually routes.
|
|
92
|
+
//
|
|
93
|
+
// VISIBILITY, stated rather than silently decided: this returns open goals
|
|
94
|
+
// without filtering on `visibility`, which MATCHES the existing GET /goals — that
|
|
95
|
+
// route already lists private goals' titles to any signed-in builder
|
|
96
|
+
// (db.listGoals has no visibility predicate). So this neither widens nor narrows
|
|
97
|
+
// the status quo. It is worth knowing that the status quo is wider than the
|
|
98
|
+
// private-goal leak rule the CLAIM refusal follows (goal-authz
|
|
99
|
+
// goalMembershipClaimRefusal withholds a private goal's title); reconciling the
|
|
100
|
+
// two is a real decision about the goal board as a whole, not something a
|
|
101
|
+
// suggester should settle on its own.
|
|
102
|
+
function goalSuggestHandler({ db, LIMITS }) {
|
|
103
|
+
return async function getGoalSuggestions(req, res) {
|
|
104
|
+
const versionId = String(req.query.version || '').trim();
|
|
105
|
+
if (!versionId) return res.fail('version_required', 400, { message: 'Pass ?version=<version_id> — suggestions are scoped to one version.' });
|
|
106
|
+
const title = String(req.query.title || '').slice(0, (LIMITS && LIMITS.TITLE) || 200);
|
|
107
|
+
const body = String(req.query.body || '').slice(0, 4000);
|
|
108
|
+
if (!title && !body) {
|
|
109
|
+
return res.fail('draft_text_required', 400, { message: 'Pass ?title= (and optionally ?body=) — there is nothing to rank against otherwise.' });
|
|
110
|
+
}
|
|
111
|
+
const rawLimit = Number(req.query.limit);
|
|
112
|
+
const limit = Number.isFinite(rawLimit) ? Math.min(Math.max(1, Math.trunc(rawLimit)), 20) : SUGGEST_LIMIT;
|
|
113
|
+
// Wrapped like criteriaSuggestHandler below: without it a db error escapes to
|
|
114
|
+
// Express's default handler, which can put internal detail on a JSON API
|
|
115
|
+
// response. The sibling in this same file already did this; not matching it
|
|
116
|
+
// was an oversight, not a decision.
|
|
117
|
+
try {
|
|
118
|
+
const open = await db.listGoals({ versionId, status: 'open' });
|
|
119
|
+
const suggestions = suggestGoals(open, { title, body }, { versionId, limit });
|
|
120
|
+
return res.json({
|
|
121
|
+
version_id: versionId,
|
|
122
|
+
// `kind` is echoed, NOT ranked on. Kind decides how a capture LANDS (the
|
|
123
|
+
// R05 landing matrix: a bug hard-lands, a feature soft-lands); it does not
|
|
124
|
+
// indicate WHICH goal the work belongs to. Saying so here stops a caller
|
|
125
|
+
// reading the echo as a ranking input.
|
|
126
|
+
kind: req.query.kind ? String(req.query.kind).slice(0, 64) : null,
|
|
127
|
+
suggestions,
|
|
128
|
+
// So a caller can offer "browse all" honestly when nothing scored: an empty
|
|
129
|
+
// `suggestions` means "no confident match", never "this version has no goals".
|
|
130
|
+
open_goal_count: (open || []).length,
|
|
131
|
+
created: null,
|
|
132
|
+
});
|
|
133
|
+
} catch (err) {
|
|
134
|
+
proseErr(err);
|
|
135
|
+
return res.fail('suggest_failed', { status: 500, message: 'internal error' });
|
|
136
|
+
}
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// criteriaSuggestHandler — GET /goals/:id/suggest-criteria (task 2165), moved
|
|
141
|
+
// here in task 1003062. Same reason as its sibling above: routes/goals.js is over
|
|
142
|
+
// the fitness oversize threshold and the ratchet is shrink-only, so adding the
|
|
143
|
+
// goal suggester there had to be PAID FOR — and paying with a cohesion
|
|
144
|
+
// improvement beats shaving comments. It belongs beside the other suggester
|
|
145
|
+
// anyway: both answer "what should this draft attach to", one at goal level and
|
|
146
|
+
// one at criterion level. Behaviour byte-identical; only the registration line
|
|
147
|
+
// stays in the route file so route-rank-check still classifies it.
|
|
148
|
+
function criteriaSuggestHandler({ db, suggestCriteria, parseId }) {
|
|
149
|
+
return async function getSuggestCriteria(req, res) {
|
|
150
|
+
const id = parseId(req, res, { code: 'bad_goal_id' });
|
|
151
|
+
if (id === null) return;
|
|
152
|
+
try {
|
|
153
|
+
const goal = await db.getGoal(id);
|
|
154
|
+
if (!goal) return res.fail('goal_not_found', 404);
|
|
155
|
+
const criteria = await db.listCriteriaForGoal(id);
|
|
156
|
+
const limit = Math.min(10, Math.max(1, Number(req.query.limit) || 3));
|
|
157
|
+
const suggestions = suggestCriteria(
|
|
158
|
+
criteria,
|
|
159
|
+
{ title: req.query.title, description: req.query.description },
|
|
160
|
+
{ limit }
|
|
161
|
+
);
|
|
162
|
+
return res.json({ goal_id: goal.id, suggestions });
|
|
163
|
+
} catch (err) {
|
|
164
|
+
// Same seam as its sibling above — one error-handling pattern per file.
|
|
165
|
+
api.logger('lifecycle').error({ err: err && err.message }, 'GET /goals/:id/suggest-criteria failed');
|
|
166
|
+
return res.fail('fetch_failed', { status: 500, message: 'internal error' });
|
|
167
|
+
}
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
module.exports = { suggestGoals, goalSuggestHandler, criteriaSuggestHandler, SUGGEST_LIMIT };
|
|
@@ -26,6 +26,7 @@ const api = require('../../../src/module-api');
|
|
|
26
26
|
const auth = api;
|
|
27
27
|
const db = require('../db');
|
|
28
28
|
const hierarchy = require('../hierarchy');
|
|
29
|
+
const goalSuggest = require('../goal-suggest.js'); // task 1003062 — pre-creation goal suggester
|
|
29
30
|
const goalEdits = require('../goal-edits.js'); // task 1003102 — goal self-edit handlers
|
|
30
31
|
const { suggestCriteria } = require('../criterion-suggest');
|
|
31
32
|
const { classifyTaskKind, VALID_KINDS, VALID_DISCIPLINES, TASK_FIELD_BOUNDS } = require('../task-classifier');
|
|
@@ -535,6 +536,13 @@ module.exports = function buildGoalsRouter() {
|
|
|
535
536
|
}
|
|
536
537
|
});
|
|
537
538
|
|
|
539
|
+
// GET /goals/suggestions — "which goal should this draft live in?", answered
|
|
540
|
+
// before anything is created (task 1003062 / R07). Handler + the ranking and
|
|
541
|
+
// visibility rationale: ../goal-suggest.js. Mounted BEFORE GET /goals/:id so
|
|
542
|
+
// 'suggestions' is never captured as an id.
|
|
543
|
+
// rank: any-builder — a read of open goal titles, same surface as GET /goals.
|
|
544
|
+
router.get('/goals/suggestions', auth.requireBuilder, goalSuggest.goalSuggestHandler({ db, LIMITS }));
|
|
545
|
+
|
|
538
546
|
// GET /goals/inbox — the caller's private-goals inbox (task 1952 / ADR 0112):
|
|
539
547
|
// { invitations: invites addressed to me (accept/decline); joinRequests: pending
|
|
540
548
|
// join-requests on goals I own or manage (approve/deny); myPendingRequests: my own
|
|
@@ -578,25 +586,7 @@ module.exports = function buildGoalsRouter() {
|
|
|
578
586
|
// task, so a create flow can SUGGEST which done-when criterion to link (the
|
|
579
587
|
// author confirms/overrides — nothing is auto-linked). Read-only + advisory;
|
|
580
588
|
// rank: any-builder (same as GET /goals/:id).
|
|
581
|
-
router.get('/goals/:id/suggest-criteria', auth.requireBuilder,
|
|
582
|
-
const id = parseId(req, res, { code: 'bad_goal_id' });
|
|
583
|
-
if (id === null) return;
|
|
584
|
-
try {
|
|
585
|
-
const goal = await db.getGoal(id);
|
|
586
|
-
if (!goal) return res.fail('goal_not_found', 404);
|
|
587
|
-
const criteria = await db.listCriteriaForGoal(id);
|
|
588
|
-
const limit = Math.min(10, Math.max(1, Number(req.query.limit) || 3));
|
|
589
|
-
const suggestions = suggestCriteria(
|
|
590
|
-
criteria,
|
|
591
|
-
{ title: req.query.title, description: req.query.description },
|
|
592
|
-
{ limit }
|
|
593
|
-
);
|
|
594
|
-
return res.json({ goal_id: goal.id, suggestions });
|
|
595
|
-
} catch (err) {
|
|
596
|
-
console.error('[gds] GET /goals/:id/suggest-criteria', err);
|
|
597
|
-
return res.fail('fetch_failed', { status: 500, message: 'internal error' });
|
|
598
|
-
}
|
|
599
|
-
});
|
|
589
|
+
router.get('/goals/:id/suggest-criteria', auth.requireBuilder, goalSuggest.criteriaSuggestHandler({ db, suggestCriteria, parseId }));
|
|
600
590
|
|
|
601
591
|
// POST /goals/:id/join — join an open goal.
|
|
602
592
|
// rank: any-builder — open join for a NORMAL goal. A PROTECTED-scope goal is
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.289",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.19.
|
|
9
|
+
"version": "1.19.289",
|
|
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.
|
|
3
|
+
"version": "1.19.289",
|
|
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",
|
|
@@ -362,6 +362,9 @@ const MODULE_EXPECTED_RANKS = {
|
|
|
362
362
|
// Pinning catches an accidental requireRank re-gate that would lock builders
|
|
363
363
|
// out of asking/accepting, and any gate removal.
|
|
364
364
|
'GET /goals/inbox': 'any-builder',
|
|
365
|
+
// task 1003062 (R07): a pre-creation READ of open goal titles — same surface
|
|
366
|
+
// and same rank as GET /goals. Creates nothing.
|
|
367
|
+
'GET /goals/suggestions': 'any-builder',
|
|
365
368
|
'PATCH /goals/:id/visibility': 'any-builder',
|
|
366
369
|
// task 1003102: a goal's own authority (owner|manager|Archon, in-handler
|
|
367
370
|
// authorizeGoalManagement) corrects its own title/description — same shape
|
|
@@ -506,8 +509,20 @@ function classifyGate(lines, i, { floors } = {}) {
|
|
|
506
509
|
// (not any `=>`) avoids cutting at an inline-arrow middleware like
|
|
507
510
|
// `rateLimit({ key: (req) => req.ip })`. If there's no `=> {` in the window
|
|
508
511
|
// (e.g. a `function` handler), fall back to the full window — prior behaviour.
|
|
509
|
-
|
|
510
|
-
|
|
512
|
+
// task 1003062: FIRST clamp the window to THIS route's own declaration. The
|
|
513
|
+
// `=> {` truncation below only fires for an inline-arrow handler; a route whose
|
|
514
|
+
// handler is a FACTORY CALL — `router.get(p, auth.requireBuilder, mk({...}))` —
|
|
515
|
+
// contains no `=> {`, so the old code fell back to the full 20 lines and
|
|
516
|
+
// absorbed the NEXT route's requirePermission, recording an any-builder read as
|
|
517
|
+
// metic+archon. Wrong in the tightening direction, but still wrong: the pin and
|
|
518
|
+
// the audit then describe a gate that is not there. Factory handlers became
|
|
519
|
+
// common because the fitness oversize ratchet pushes handler bodies out of large
|
|
520
|
+
// route files, so this is not an exotic shape. A window must never cross into a
|
|
521
|
+
// sibling declaration.
|
|
522
|
+
const nextRouteAt = window.search(/\n\s*router\.(get|post|patch|put|delete|use)\s*\(/);
|
|
523
|
+
const own = nextRouteAt === -1 ? window : window.slice(0, nextRouteAt);
|
|
524
|
+
const handlerAt = own.search(/=>\s*\{/);
|
|
525
|
+
const tail = handlerAt === -1 ? own : own.slice(0, handlerAt);
|
|
511
526
|
|
|
512
527
|
const perms = permissionKeysIn(tail);
|
|
513
528
|
const byRank = rankFromRankGate(tail);
|
package/src/module-api.js
CHANGED
|
@@ -1003,8 +1003,10 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
|
|
|
1003
1003
|
// landed since 1.19.286 with no explicit bump. run 32539649191. (task 1002620)
|
|
1004
1004
|
// 1.19.288 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1005
1005
|
// landed since 1.19.287 with no explicit bump. run 32546561232. (task 1002620)
|
|
1006
|
+
// 1.19.289 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1007
|
+
// landed since 1.19.288 with no explicit bump. run 32549912116. (task 1002620)
|
|
1006
1008
|
// ---------------------------------------------------------------------------
|
|
1007
|
-
const CORE_VERSION = '1.19.
|
|
1009
|
+
const CORE_VERSION = '1.19.289'; // CI auto-patch carrier (ADR 0161); see changelog above
|
|
1008
1010
|
|
|
1009
1011
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
1010
1012
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
// tests/goal_suggest.mjs — the pre-creation goal suggester (task 1003062 / R07 of
|
|
2
|
+
// goal 1000071).
|
|
3
|
+
//
|
|
4
|
+
// "Which goal should this work live in?", answered BEFORE anything is created.
|
|
5
|
+
// buildGoalAdvisory already answered it, but only AFTER a task existed; R08 (hall
|
|
6
|
+
// picker), R09 (CLI) and R11 (Discord) all have a draft title and no row yet.
|
|
7
|
+
//
|
|
8
|
+
// What these pin:
|
|
9
|
+
// 1. It CREATES NOTHING. That is the entire point of a pre-creation read, and the
|
|
10
|
+
// easiest thing for a later refactor to break by routing it through a writer.
|
|
11
|
+
// 2. RANKING, not "the first five open goals" — with the honest limits of token
|
|
12
|
+
// overlap written down rather than papered over (see the false-positive test).
|
|
13
|
+
// 3. The catch-all is never suggested (it is the fallback when there is no home —
|
|
14
|
+
// a different question, owned by R03).
|
|
15
|
+
// 4. No confident match returns [] rather than noise, and the route still tells
|
|
16
|
+
// the caller how many goals exist so it can offer "browse all" honestly.
|
|
17
|
+
//
|
|
18
|
+
// Run: node tests/goal_suggest.mjs
|
|
19
|
+
|
|
20
|
+
import { strict as assert } from 'node:assert';
|
|
21
|
+
import { createRequire } from 'node:module';
|
|
22
|
+
import { readFileSync } from 'node:fs';
|
|
23
|
+
import { makeRunner } from './helpers.mjs';
|
|
24
|
+
|
|
25
|
+
process.env.NODE_ENV = 'test';
|
|
26
|
+
const require = createRequire(import.meta.url);
|
|
27
|
+
const { suggestGoals, goalSuggestHandler, SUGGEST_LIMIT } = require('../modules/lifecycle/goal-suggest.js');
|
|
28
|
+
const { test, summary } = makeRunner();
|
|
29
|
+
|
|
30
|
+
const V = 'BONGOS-V1';
|
|
31
|
+
const GOALS = [
|
|
32
|
+
{ id: 1000024, title: 'Build-pipeline integrity', scope_modules: ['lifecycle', 'grading'] },
|
|
33
|
+
{ id: 1000025, title: 'Builder experience — hall, dev box, and skills', scope_modules: ['hall-ui', 'dev-box'] },
|
|
34
|
+
{ id: 1000071, title: 'Idea routing — work that has a home lands in it', scope_modules: ['ideas', 'discord'] },
|
|
35
|
+
{ id: 1000003, title: `${V} — general`, scope_modules: [] },
|
|
36
|
+
];
|
|
37
|
+
|
|
38
|
+
// ---- 2. it ranks, and the ranking is explainable --------------------------
|
|
39
|
+
|
|
40
|
+
await test('RANKS: a draft about the ship pipeline puts Build-pipeline integrity first', () => {
|
|
41
|
+
const r = suggestGoals(GOALS, { title: 'ship pipeline grading is flaky' }, { versionId: V });
|
|
42
|
+
assert.equal(r[0].id, 1000024);
|
|
43
|
+
assert.ok(r[0].score >= 4, `two title terms should score >=4, got ${r[0].score}`);
|
|
44
|
+
assert.deepEqual(r[0].matched_terms, ['grading', 'pipeline'], 'and it SAYS why it matched');
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
await test('RANKS: a module key is a matchable signal, not just the title', () => {
|
|
48
|
+
// 'discord' appears only in scope_modules for goal 1000071.
|
|
49
|
+
const r = suggestGoals(GOALS, { title: 'discord filing loses the author' }, { versionId: V });
|
|
50
|
+
assert.equal(r[0].id, 1000071);
|
|
51
|
+
assert.ok(r[0].matched_terms.includes('discord'));
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
await test('RANKS: title terms outweigh body terms (2 vs 1), same rule as suggestCriteria', () => {
|
|
55
|
+
const inTitle = suggestGoals(GOALS, { title: 'grading' }, { versionId: V })[0];
|
|
56
|
+
const inBody = suggestGoals(GOALS, { title: 'unrelated', body: 'grading' }, { versionId: V })[0];
|
|
57
|
+
assert.equal(inTitle.score, 2);
|
|
58
|
+
assert.equal(inBody.score, 1);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
await test('RANKS: a term in BOTH title and body counts once, at the title weight', () => {
|
|
62
|
+
const r = suggestGoals(GOALS, { title: 'grading', body: 'grading grading' }, { versionId: V })[0];
|
|
63
|
+
assert.equal(r.score, 2, 'the stronger signal wins; it does not accumulate');
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
await test('RANKS: ties break by ascending id so a picker renders stably', () => {
|
|
67
|
+
const goals = [
|
|
68
|
+
{ id: 900, title: 'routing', scope_modules: [] },
|
|
69
|
+
{ id: 100, title: 'routing', scope_modules: [] },
|
|
70
|
+
{ id: 500, title: 'routing', scope_modules: [] },
|
|
71
|
+
];
|
|
72
|
+
assert.deepEqual(suggestGoals(goals, { title: 'routing' }, { versionId: V }).map((g) => g.id), [100, 500, 900]);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
// ---- the honest limit, written down --------------------------------------
|
|
76
|
+
|
|
77
|
+
await test('LIMIT (known + accepted): token overlap can match on an incidental word', () => {
|
|
78
|
+
// Real example found while building this: "the goals page search BOX is broken"
|
|
79
|
+
// scores the DEV BOX goal, because both contain the token 'box'. Bag-of-words
|
|
80
|
+
// matching cannot tell "search box" from "dev box".
|
|
81
|
+
//
|
|
82
|
+
// This is NOT hidden — it is why every suggestion carries `score` and
|
|
83
|
+
// `matched_terms`: a caller can show WHY it matched and a human decides. A
|
|
84
|
+
// suggester that silently guessed, with no visible reason, would be worse.
|
|
85
|
+
// Fixing it properly means real relevance scoring (embeddings/FTS), which is a
|
|
86
|
+
// separate decision, not something to smuggle into R07.
|
|
87
|
+
const r = suggestGoals(GOALS, { title: 'the goals page search box is broken' }, { versionId: V });
|
|
88
|
+
assert.equal(r[0].id, 1000025, 'the incidental match is what happens today');
|
|
89
|
+
assert.deepEqual(r[0].matched_terms, ['box'], 'and the single weak term is VISIBLE to the caller');
|
|
90
|
+
assert.equal(r[0].score, 2, 'one title term — a low score, which is the signal it is weak');
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
// ---- 3. the catch-all is never a suggestion ------------------------------
|
|
94
|
+
|
|
95
|
+
await test('CATCH-ALL: the "<version> — general" goal is never suggested, even on an exact hit', () => {
|
|
96
|
+
const r = suggestGoals(GOALS, { title: `${V} general` }, { versionId: V });
|
|
97
|
+
assert.ok(!r.some((g) => g.id === 1000003), 'the catch-all is the no-home FALLBACK (R03), not a suggestion');
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
await test('CATCH-ALL: without a versionId it cannot be identified, so nothing is dropped', () => {
|
|
101
|
+
// candidateGoals keys the catch-all off the version name; no version => no filter.
|
|
102
|
+
const r = suggestGoals(GOALS, { title: 'general' }, {});
|
|
103
|
+
assert.ok(r.some((g) => g.id === 1000003), 'documented consequence of omitting versionId');
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
// ---- 4. no confident match, and bounds ----------------------------------
|
|
107
|
+
|
|
108
|
+
await test('NO MATCH: a draft matching nothing returns [] rather than arbitrary goals', () => {
|
|
109
|
+
assert.deepEqual(suggestGoals(GOALS, { title: 'xyzzy plugh frobnicate' }, { versionId: V }), []);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
await test('NO MATCH: includeZero:true is available for a caller that wants the full list', () => {
|
|
113
|
+
const r = suggestGoals(GOALS, { title: 'xyzzy' }, { versionId: V, includeZero: true });
|
|
114
|
+
assert.equal(r.length, 3, 'all three non-catch-all goals, all scoring 0');
|
|
115
|
+
assert.ok(r.every((g) => g.score === 0));
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
await test('BOUNDS: limit defaults to SUGGEST_LIMIT and is respected; degenerate input is safe', () => {
|
|
119
|
+
assert.equal(SUGGEST_LIMIT, 5);
|
|
120
|
+
assert.equal(suggestGoals(GOALS, { title: 'routing' }, { versionId: V, limit: 1 }).length, 1);
|
|
121
|
+
assert.deepEqual(suggestGoals(GOALS, { title: 'routing' }, { versionId: V, limit: 0 }), []);
|
|
122
|
+
assert.deepEqual(suggestGoals(null, { title: 'x' }, {}), []);
|
|
123
|
+
assert.deepEqual(suggestGoals(GOALS, {}, { versionId: V }), [], 'no draft text => no suggestions');
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
// ---- 1. IT CREATES NOTHING ----------------------------------------------
|
|
127
|
+
|
|
128
|
+
function fakeRes() {
|
|
129
|
+
return {
|
|
130
|
+
code: null, payload: null, statusCode: 200,
|
|
131
|
+
json(p) { this.payload = p; return this; },
|
|
132
|
+
status(c) { this.statusCode = c; return this; },
|
|
133
|
+
fail(code, s, extra) { this.code = code; this.statusCode = typeof s === 'number' ? s : (s && s.status) || 500; this.payload = extra || s; return this; },
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
await test('CREATES NOTHING: the handler only READS goals — no writer is reachable from it', async () => {
|
|
138
|
+
const calls = [];
|
|
139
|
+
const db = { listGoals: async (a) => { calls.push(['listGoals', a]); return GOALS; } };
|
|
140
|
+
const handler = goalSuggestHandler({ db, LIMITS: { TITLE: 200 } });
|
|
141
|
+
const res = fakeRes();
|
|
142
|
+
await handler({ query: { version: V, title: 'ship pipeline grading' } }, res);
|
|
143
|
+
assert.equal(res.code, null, `expected success, got ${res.code}`);
|
|
144
|
+
assert.equal(res.payload.suggestions[0].id, 1000024);
|
|
145
|
+
assert.deepEqual(calls.map((c) => c[0]), ['listGoals'], 'exactly one read, and it is a read');
|
|
146
|
+
assert.equal(res.payload.created, null, 'the response says so explicitly');
|
|
147
|
+
// Source contract: nothing in this module may call a create/insert/update path.
|
|
148
|
+
const src = readFileSync(new URL('../modules/lifecycle/goal-suggest.js', import.meta.url), 'utf8');
|
|
149
|
+
assert.ok(!/\b(createTask|createGoal|insert|INSERT|UPDATE|captureIdea)\b/.test(src),
|
|
150
|
+
'a pre-creation suggester must contain no write path at all');
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
await test('the handler refuses without a version, and without any draft text', async () => {
|
|
154
|
+
const handler = goalSuggestHandler({ db: { listGoals: async () => GOALS }, LIMITS: { TITLE: 200 } });
|
|
155
|
+
let res = fakeRes();
|
|
156
|
+
await handler({ query: { title: 'x' } }, res);
|
|
157
|
+
assert.equal(res.code, 'version_required');
|
|
158
|
+
res = fakeRes();
|
|
159
|
+
await handler({ query: { version: V } }, res);
|
|
160
|
+
assert.equal(res.code, 'draft_text_required', 'ranking against nothing is not a suggestion');
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
await test('the handler reports open_goal_count so an empty result is not mistaken for an empty version', async () => {
|
|
164
|
+
const handler = goalSuggestHandler({ db: { listGoals: async () => GOALS }, LIMITS: { TITLE: 200 } });
|
|
165
|
+
const res = fakeRes();
|
|
166
|
+
await handler({ query: { version: V, title: 'xyzzy plugh' } }, res);
|
|
167
|
+
assert.deepEqual(res.payload.suggestions, []);
|
|
168
|
+
assert.equal(res.payload.open_goal_count, 4, 'four goals exist; none matched — different facts');
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
await test('kind is ECHOED but never ranked on (it decides landing, not which goal)', async () => {
|
|
172
|
+
const handler = goalSuggestHandler({ db: { listGoals: async () => GOALS }, LIMITS: { TITLE: 200 } });
|
|
173
|
+
const withKind = fakeRes();
|
|
174
|
+
await handler({ query: { version: V, title: 'grading', kind: 'bug' } }, withKind);
|
|
175
|
+
const noKind = fakeRes();
|
|
176
|
+
await handler({ query: { version: V, title: 'grading' } }, noKind);
|
|
177
|
+
assert.equal(withKind.payload.kind, 'bug', 'echoed');
|
|
178
|
+
assert.equal(noKind.payload.kind, null);
|
|
179
|
+
assert.deepEqual(
|
|
180
|
+
withKind.payload.suggestions.map((s) => [s.id, s.score]),
|
|
181
|
+
noKind.payload.suggestions.map((s) => [s.id, s.score]),
|
|
182
|
+
'kind must not change the ranking — the R05 landing matrix owns kind',
|
|
183
|
+
);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
await test('the handler clamps limit into a sane range', async () => {
|
|
187
|
+
const handler = goalSuggestHandler({ db: { listGoals: async () => GOALS }, LIMITS: { TITLE: 200 } });
|
|
188
|
+
for (const [q, expectMax] of [[{ limit: '999' }, 20], [{ limit: '0' }, 1], [{ limit: 'abc' }, SUGGEST_LIMIT]]) {
|
|
189
|
+
const res = fakeRes();
|
|
190
|
+
await handler({ query: { version: V, title: 'routing grading hall', ...q } }, res);
|
|
191
|
+
assert.ok(res.payload.suggestions.length <= expectMax, `limit ${JSON.stringify(q)} must clamp`);
|
|
192
|
+
}
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
summary();
|