@bongos/core 1.19.163 → 1.19.165
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/index.d.ts +2 -3
- package/docs/api/openapi.json +13 -23
- package/docs/api-reference.md +1 -1
- package/modules/hall-ui/public/goals-page.js +94 -7
- package/modules/hall-ui/public/goals.css +3 -0
- package/modules/ideas/routes/inbox.js +67 -1
- package/modules/ideas/templates.js +15 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/seed-bv1-privacy-invites-tasks.js +133 -0
- package/src/module-api.js +5 -1
- package/tests/idea_full_capture.mjs +146 -0
- package/tests/idea_quick_guard.mjs +10 -6
- package/tests/idea_templates.mjs +16 -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.165",
|
|
6
|
+
"core_contract": "1.19.165",
|
|
7
|
+
"source_commit": "3765c2891e2cf0e70f9b4f0fbe112eaa2f05faaf",
|
|
8
8
|
"source_ref": "HEAD",
|
|
9
|
-
"built_at": "2026-08-
|
|
9
|
+
"built_at": "2026-08-16T17:13:47.567Z",
|
|
10
10
|
"redaction": {
|
|
11
11
|
"model": "docs-redacted+functional-verbatim",
|
|
12
12
|
"docs_redacted": 305,
|
|
13
13
|
"agent_docs_stubbed": 21,
|
|
14
|
-
"functional_verbatim":
|
|
14
|
+
"functional_verbatim": 1264,
|
|
15
15
|
"rules": 3,
|
|
16
16
|
"gate_literals": 3,
|
|
17
17
|
"gate": "passed"
|
|
18
18
|
},
|
|
19
|
-
"file_count":
|
|
20
|
-
"tree_sha256": "
|
|
19
|
+
"file_count": 1590,
|
|
20
|
+
"tree_sha256": "9462bf715e231d13175d8e0ad6eb1464cf46cc4894de3f644c025a79b6bfa78a",
|
|
21
21
|
"files": [
|
|
22
22
|
{
|
|
23
23
|
"path": ".claude/skills/blocker-review/SKILL.md",
|
|
@@ -367,7 +367,7 @@
|
|
|
367
367
|
{
|
|
368
368
|
"path": "clients/bongos-client/index.d.ts",
|
|
369
369
|
"mode": "0000644",
|
|
370
|
-
"sha256": "
|
|
370
|
+
"sha256": "4245c4fb82c1c4f5c87788030dc4640987fe22e0d588072d4549e9982cea7ecc"
|
|
371
371
|
},
|
|
372
372
|
{
|
|
373
373
|
"path": "clients/bongos-client/index.mjs",
|
|
@@ -1347,12 +1347,12 @@
|
|
|
1347
1347
|
{
|
|
1348
1348
|
"path": "docs/api-reference.md",
|
|
1349
1349
|
"mode": "0000644",
|
|
1350
|
-
"sha256": "
|
|
1350
|
+
"sha256": "879ef6fd06bde11c040c5ef67ccf495f0be6eb95e11be6f8979c9d560a33da64"
|
|
1351
1351
|
},
|
|
1352
1352
|
{
|
|
1353
1353
|
"path": "docs/api/openapi.json",
|
|
1354
1354
|
"mode": "0000644",
|
|
1355
|
-
"sha256": "
|
|
1355
|
+
"sha256": "9382373e7be837c0ce4e26f23c3cd32bf20ab09858650bdee318dd90a06dedbe"
|
|
1356
1356
|
},
|
|
1357
1357
|
{
|
|
1358
1358
|
"path": "docs/architecture.md",
|
|
@@ -3167,12 +3167,12 @@
|
|
|
3167
3167
|
{
|
|
3168
3168
|
"path": "modules/hall-ui/public/goals-page.js",
|
|
3169
3169
|
"mode": "0000644",
|
|
3170
|
-
"sha256": "
|
|
3170
|
+
"sha256": "7e67230de060927c37ab8680e397c994896250c9e027e920ac997a61edbd14aa"
|
|
3171
3171
|
},
|
|
3172
3172
|
{
|
|
3173
3173
|
"path": "modules/hall-ui/public/goals.css",
|
|
3174
3174
|
"mode": "0000644",
|
|
3175
|
-
"sha256": "
|
|
3175
|
+
"sha256": "407e8f21b95faa725d37c5336986d00af734db351bdaaf58aac350114c58f172"
|
|
3176
3176
|
},
|
|
3177
3177
|
{
|
|
3178
3178
|
"path": "modules/hall-ui/public/goals.html",
|
|
@@ -3482,12 +3482,12 @@
|
|
|
3482
3482
|
{
|
|
3483
3483
|
"path": "modules/ideas/routes/inbox.js",
|
|
3484
3484
|
"mode": "0000644",
|
|
3485
|
-
"sha256": "
|
|
3485
|
+
"sha256": "8604c627617cc90fa3197dcf3776ce708546d754b9acc2d45c16dfea97a478cb"
|
|
3486
3486
|
},
|
|
3487
3487
|
{
|
|
3488
3488
|
"path": "modules/ideas/templates.js",
|
|
3489
3489
|
"mode": "0000644",
|
|
3490
|
-
"sha256": "
|
|
3490
|
+
"sha256": "eade9a2d07da0a39af17c086d188a195be00c544889d61454ed3c6368d39ea04"
|
|
3491
3491
|
},
|
|
3492
3492
|
{
|
|
3493
3493
|
"path": "modules/lifecycle/CLAUDE.md",
|
|
@@ -4202,12 +4202,12 @@
|
|
|
4202
4202
|
{
|
|
4203
4203
|
"path": "package-lock.json",
|
|
4204
4204
|
"mode": "0000644",
|
|
4205
|
-
"sha256": "
|
|
4205
|
+
"sha256": "e02feb552451ccc0b6011326d1e44776920e00d0cde6d5f14d998e3353d7281f"
|
|
4206
4206
|
},
|
|
4207
4207
|
{
|
|
4208
4208
|
"path": "package.json",
|
|
4209
4209
|
"mode": "0000644",
|
|
4210
|
-
"sha256": "
|
|
4210
|
+
"sha256": "4b5c6f1642f870b8048fcf8faf1873676e3a0a95a30a30e6cfc1fd615794f944"
|
|
4211
4211
|
},
|
|
4212
4212
|
{
|
|
4213
4213
|
"path": "public-docs/index.html",
|
|
@@ -5259,6 +5259,11 @@
|
|
|
5259
5259
|
"mode": "0000644",
|
|
5260
5260
|
"sha256": "5de4203d6a764c8aec374c7802f69f659816d2c0d75bbe70144f7dfb56df2e39"
|
|
5261
5261
|
},
|
|
5262
|
+
{
|
|
5263
|
+
"path": "scripts/gds/seed-bv1-privacy-invites-tasks.js",
|
|
5264
|
+
"mode": "0000644",
|
|
5265
|
+
"sha256": "bab0c429d0d194cc5e90bc09d59c338cf85143e0788bc3071f94297982c9a5fe"
|
|
5266
|
+
},
|
|
5262
5267
|
{
|
|
5263
5268
|
"path": "scripts/gds/seed-bv1-profile-page-tasks.js",
|
|
5264
5269
|
"mode": "0000644",
|
|
@@ -5802,7 +5807,7 @@
|
|
|
5802
5807
|
{
|
|
5803
5808
|
"path": "src/module-api.js",
|
|
5804
5809
|
"mode": "0000644",
|
|
5805
|
-
"sha256": "
|
|
5810
|
+
"sha256": "188546b1d9a2f94016b5d7d8f647f9e8092781677f7370f55c78c8308879e490"
|
|
5806
5811
|
},
|
|
5807
5812
|
{
|
|
5808
5813
|
"path": "src/module-loader/catalog.js",
|
|
@@ -6914,6 +6919,11 @@
|
|
|
6914
6919
|
"mode": "0000644",
|
|
6915
6920
|
"sha256": "e7b6af463185ea77f40d62408026c55c7fb077f18999b972564b3b5bc7656ca5"
|
|
6916
6921
|
},
|
|
6922
|
+
{
|
|
6923
|
+
"path": "tests/idea_full_capture.mjs",
|
|
6924
|
+
"mode": "0000644",
|
|
6925
|
+
"sha256": "95b53f1acfa61f723741b4d33b5e882525903ccc549ae01f3094a788251a60ec"
|
|
6926
|
+
},
|
|
6917
6927
|
{
|
|
6918
6928
|
"path": "tests/idea_grades.mjs",
|
|
6919
6929
|
"mode": "0000644",
|
|
@@ -6922,12 +6932,12 @@
|
|
|
6922
6932
|
{
|
|
6923
6933
|
"path": "tests/idea_quick_guard.mjs",
|
|
6924
6934
|
"mode": "0000644",
|
|
6925
|
-
"sha256": "
|
|
6935
|
+
"sha256": "729c511f9bb3d4a9b7fb76c7b3843e1900c8d4c9cfa30fe08e482d1e41113839"
|
|
6926
6936
|
},
|
|
6927
6937
|
{
|
|
6928
6938
|
"path": "tests/idea_templates.mjs",
|
|
6929
6939
|
"mode": "0000644",
|
|
6930
|
-
"sha256": "
|
|
6940
|
+
"sha256": "943afa2366f92c4d6188ce9f8d68f499aac8d67930f91d6860a2cb83020642ab"
|
|
6931
6941
|
},
|
|
6932
6942
|
{
|
|
6933
6943
|
"path": "tests/idea_triage.mjs",
|
|
@@ -222,8 +222,7 @@ export interface PostGovernanceRolesRequest { role_key: string; label: string; d
|
|
|
222
222
|
export interface PostGovernanceRolesResponse { role: unknown }
|
|
223
223
|
export interface PostGovernanceRolesRoleKeyResetResponse { ok: boolean; role_key: unknown; seeded_rank: unknown; permission_count: unknown }
|
|
224
224
|
export interface PostInboxIdRatifyRequest { notes_md?: string }
|
|
225
|
-
export interface PostInboxRequest { title: string; body_md?: string; kind?: string; suggested_version?: string; suggested_priority?: number }
|
|
226
|
-
export interface PostInboxResponse { idea: unknown; open_count: unknown; kind_auto: unknown }
|
|
225
|
+
export interface PostInboxRequest { title: string; body_md?: string; kind?: string; suggested_version?: string; suggested_priority?: number; grade?: string; template_key?: string; field_values?: Record<string, unknown> }
|
|
227
226
|
export interface PostLearningsRequest { title: string; body_md: string; tags?: string[]; related_files?: string[]; source?: string; source_ref?: string; task_id?: number }
|
|
228
227
|
export interface PostLearningsResponse { skipped?: boolean; reason?: unknown; learning?: unknown }
|
|
229
228
|
export interface PostLlmCacheLookupRequest { cache_key?: unknown; tokens_saved_est?: unknown }
|
|
@@ -607,7 +606,7 @@ export interface BongosClient {
|
|
|
607
606
|
/** GET /inbox — rank: any-builder */
|
|
608
607
|
getInbox(args?: RequestArgs): Promise<GetInboxResponse>;
|
|
609
608
|
/** POST /inbox — rank: any-builder */
|
|
610
|
-
postInbox(args: RequestArgs & { body: PostInboxRequest }): Promise<
|
|
609
|
+
postInbox(args: RequestArgs & { body: PostInboxRequest }): Promise<ApiResponse>;
|
|
611
610
|
/** GET /inbox/{id} — rank: any-builder */
|
|
612
611
|
getInboxId(args?: RequestArgs): Promise<GetInboxIdResponse>;
|
|
613
612
|
/** PATCH /inbox/{id} — rank: metic+archon */
|
package/docs/api/openapi.json
CHANGED
|
@@ -5686,14 +5686,7 @@
|
|
|
5686
5686
|
},
|
|
5687
5687
|
"responses": {
|
|
5688
5688
|
"200": {
|
|
5689
|
-
"description": "Success."
|
|
5690
|
-
"content": {
|
|
5691
|
-
"application/json": {
|
|
5692
|
-
"schema": {
|
|
5693
|
-
"$ref": "#/components/schemas/PostInboxResponse"
|
|
5694
|
-
}
|
|
5695
|
-
}
|
|
5696
|
-
}
|
|
5689
|
+
"description": "Success."
|
|
5697
5690
|
},
|
|
5698
5691
|
"400": {
|
|
5699
5692
|
"$ref": "#/components/responses/ValidationFailed"
|
|
@@ -15422,6 +15415,16 @@
|
|
|
15422
15415
|
"type": "integer",
|
|
15423
15416
|
"minimum": 0,
|
|
15424
15417
|
"maximum": 100
|
|
15418
|
+
},
|
|
15419
|
+
"grade": {
|
|
15420
|
+
"type": "string",
|
|
15421
|
+
"maxLength": 16
|
|
15422
|
+
},
|
|
15423
|
+
"template_key": {
|
|
15424
|
+
"type": "string"
|
|
15425
|
+
},
|
|
15426
|
+
"field_values": {
|
|
15427
|
+
"type": "object"
|
|
15425
15428
|
}
|
|
15426
15429
|
},
|
|
15427
15430
|
"required": [
|
|
@@ -15429,19 +15432,6 @@
|
|
|
15429
15432
|
],
|
|
15430
15433
|
"additionalProperties": false
|
|
15431
15434
|
},
|
|
15432
|
-
"PostInboxResponse": {
|
|
15433
|
-
"type": "object",
|
|
15434
|
-
"properties": {
|
|
15435
|
-
"idea": {},
|
|
15436
|
-
"open_count": {},
|
|
15437
|
-
"kind_auto": {}
|
|
15438
|
-
},
|
|
15439
|
-
"required": [
|
|
15440
|
-
"idea",
|
|
15441
|
-
"open_count",
|
|
15442
|
-
"kind_auto"
|
|
15443
|
-
]
|
|
15444
|
-
},
|
|
15445
15435
|
"PostLearningsRequest": {
|
|
15446
15436
|
"type": "object",
|
|
15447
15437
|
"properties": {
|
|
@@ -17109,8 +17099,8 @@
|
|
|
17109
17099
|
}
|
|
17110
17100
|
},
|
|
17111
17101
|
"x-endpoint-count": 271,
|
|
17112
|
-
"x-schema-count":
|
|
17102
|
+
"x-schema-count": 303,
|
|
17113
17103
|
"x-undocumented-bodies": 8,
|
|
17114
|
-
"x-response-schemas":
|
|
17104
|
+
"x-response-schemas": 205,
|
|
17115
17105
|
"x-generated-by": "scripts/gds/gen-api-docs.js"
|
|
17116
17106
|
}
|
package/docs/api-reference.md
CHANGED
|
@@ -245,7 +245,7 @@ Base path: `/api/bongos`. Ranks (enforced server-side, [ADR 0016](adr/<redacted>
|
|
|
245
245
|
| Method | Path | Rank | Body | Description |
|
|
246
246
|
|---|---|---|---|---|
|
|
247
247
|
| GET | `/api/bongos/inbox` | `any-builder` | — | read open ideas; skill-friction rows filtered by viewer rank. |
|
|
248
|
-
| POST | `/api/bongos/inbox` | `any-builder` | `title`, `body_md`, `kind`, `suggested_version`, `suggested_priority` | for ordinary ideas; metic+ for kind='criterion-proposal'. |
|
|
248
|
+
| POST | `/api/bongos/inbox` | `any-builder` | `title`, `body_md`, `kind`, `suggested_version`, `suggested_priority`, `grade`, `template_key`, `field_values` | for ordinary ideas; metic+ for kind='criterion-proposal'. |
|
|
249
249
|
| GET | `/api/bongos/inbox/:id` | `any-builder` | — | single idea read for the "#NNN" deep-link resolver (task 1047). |
|
|
250
250
|
| PATCH | `/api/bongos/inbox/:id` | `metic+archon` | `status`, `kind`, `promoted_to_task_id`, `reviewed_at`, `merged_into_idea_id`, `triage_note` | Metic+ (task 360 / ADR 0018): idea triage (promote/discard/merge) shapes the backlog. |
|
|
251
251
|
| POST | `/api/bongos/inbox/:id/ratify` | `metic+archon` | `notes_md` | V3.R31 (task 252) — Archon ratifies a criterion-proposal idea, atomically: - INSERT done_when_criteria row on the target version - UPDATE… |
|
|
@@ -91,6 +91,12 @@
|
|
|
91
91
|
// from GET /tasks?version=…&include=deps. Empty on an older server (the
|
|
92
92
|
// include=deps enrichment 404s gracefully → the list falls back to plain cards).
|
|
93
93
|
rollups: new Map(),
|
|
94
|
+
// task 1002965 — goal_id → ms of the LAST WORK DONE on the goal: the newest
|
|
95
|
+
// activity timestamp across its tasks. Filled by loadRollups from the same
|
|
96
|
+
// per-version task fetch the readiness rollups ride on, so the "recently
|
|
97
|
+
// active" sort costs no extra request. A goal with no tasks is absent here
|
|
98
|
+
// and falls back to its own goals.updated_at (see goalActivityMs).
|
|
99
|
+
activity: new Map(),
|
|
94
100
|
};
|
|
95
101
|
|
|
96
102
|
async function getJSON(path) {
|
|
@@ -174,6 +180,39 @@
|
|
|
174
180
|
const EMPTY_ROLLUP = { done: 0, active: 0, ready: 0, blocked: 0, backlog: 0, cut: 0, total: 0, nextReady: null };
|
|
175
181
|
const rollupOf = (goalId) => state.rollups.get(String(goalId)) || EMPTY_ROLLUP;
|
|
176
182
|
|
|
183
|
+
// ---- goal ordering (task 1002965) -----------------------------------------
|
|
184
|
+
// The list is ordered by the Archon's within-version priority (sort_order,
|
|
185
|
+
// task 1760) unless the builder picks a date sort. Two dates are offered:
|
|
186
|
+
// created — goals.created_at, straight off GET /goals (GOAL_COLS)
|
|
187
|
+
// activity — the LAST WORK DONE on the goal: the newest task-activity
|
|
188
|
+
// timestamp under it (state.activity, filled by loadRollups),
|
|
189
|
+
// falling back to the goal row's own updated_at when the goal
|
|
190
|
+
// has no tasks at all, so a task-less goal still orders sanely
|
|
191
|
+
// instead of sinking to the bottom forever.
|
|
192
|
+
const msOf = (iso) => { const t = iso ? Date.parse(iso) : NaN; return Number.isNaN(t) ? 0 : t; };
|
|
193
|
+
const taskActivityMs = (t) => Math.max(msOf(t.updated_at), msOf(t.shipped_at));
|
|
194
|
+
const goalCreatedMs = (g) => msOf(g.created_at);
|
|
195
|
+
const goalActivityMs = (g) => state.activity.get(String(g.id)) || msOf(g.updated_at);
|
|
196
|
+
|
|
197
|
+
// value → { label, cmp, stamp } — `stamp` is what the row's meta line shows
|
|
198
|
+
// for that sort (null = show nothing extra, the priority default).
|
|
199
|
+
const GOAL_SORTS = {
|
|
200
|
+
priority: { label: 'priority (version order)', cmp: null, stamp: null },
|
|
201
|
+
newest: { label: 'newest first', cmp: (a, b) => goalCreatedMs(b) - goalCreatedMs(a), stamp: (g) => ({ label: 'created', ms: goalCreatedMs(g) }) },
|
|
202
|
+
oldest: { label: 'oldest first', cmp: (a, b) => goalCreatedMs(a) - goalCreatedMs(b), stamp: (g) => ({ label: 'created', ms: goalCreatedMs(g) }) },
|
|
203
|
+
active: { label: 'recently active', cmp: (a, b) => goalActivityMs(b) - goalActivityMs(a), stamp: (g) => ({ label: 'last work', ms: goalActivityMs(g) }) },
|
|
204
|
+
};
|
|
205
|
+
const GOAL_SORT_PREF = 'otb.goals.sort';
|
|
206
|
+
const currentGoalSort = () => GOAL_SORTS[$('#f-goal-order')?.value] || GOAL_SORTS.priority;
|
|
207
|
+
|
|
208
|
+
// Stable: filteredGoals already returns the server's priority order, and
|
|
209
|
+
// Array#sort is stable in every browser this hall supports — so goals sharing
|
|
210
|
+
// a timestamp (or both missing one) keep their priority order as the tiebreak.
|
|
211
|
+
function sortGoals(rows) {
|
|
212
|
+
const { cmp } = currentGoalSort();
|
|
213
|
+
return cmp ? rows.slice().sort(cmp) : rows;
|
|
214
|
+
}
|
|
215
|
+
|
|
177
216
|
// Fetch every task for the versions the goals span (one request each, with dep
|
|
178
217
|
// summaries) and fold into per-goal rollups. Failure-tolerant: an older server
|
|
179
218
|
// that doesn't honor include=deps just yields empty rollups and the list falls
|
|
@@ -185,6 +224,7 @@
|
|
|
185
224
|
getJSON(`${API}/tasks?version=${encodeURIComponent(v)}&include=deps`)
|
|
186
225
|
.then((r) => r.tasks || []).catch(() => [])));
|
|
187
226
|
const roll = new Map();
|
|
227
|
+
const activity = new Map();
|
|
188
228
|
for (const t of lists.flat()) {
|
|
189
229
|
if (t.goal_id == null) continue;
|
|
190
230
|
const key = String(t.goal_id);
|
|
@@ -194,8 +234,16 @@
|
|
|
194
234
|
r[bucket] += 1;
|
|
195
235
|
if (bucket !== 'cut') r.total += 1;
|
|
196
236
|
if (bucket === 'ready' && !r.nextReady) r.nextReady = t;
|
|
237
|
+
// task 1002965 — ANY task activity counts (owner call), not just ships: a
|
|
238
|
+
// goal being worked right now should read as active before anything lands.
|
|
239
|
+
// updated_at moves on every task write (claim, status flip, edit), so it
|
|
240
|
+
// dominates in practice; shipped_at is folded in because a task can be
|
|
241
|
+
// shipped by a path that stamps it without touching updated_at.
|
|
242
|
+
const ts = taskActivityMs(t);
|
|
243
|
+
if (ts && ts > (activity.get(key) || 0)) activity.set(key, ts);
|
|
197
244
|
}
|
|
198
245
|
state.rollups = roll;
|
|
246
|
+
state.activity = activity;
|
|
199
247
|
}
|
|
200
248
|
|
|
201
249
|
/* ---------------- list view ---------------- */
|
|
@@ -263,6 +311,16 @@
|
|
|
263
311
|
// task 1002439 — one goal, rendered as a rollup row: criteria ring · title +
|
|
264
312
|
// meta + readiness rollup · one next action (claim the next ready task, drill
|
|
265
313
|
// into what's blocking, or a review/achieved note).
|
|
314
|
+
// task 1002965 — under a date sort, the row states the date it was ordered on
|
|
315
|
+
// ("created Aug 4" / "last work Aug 16"); under the priority default it shows
|
|
316
|
+
// nothing extra, so the row is unchanged for anyone who never touches Sort.
|
|
317
|
+
function goalStampHtml(g) {
|
|
318
|
+
const { stamp } = currentGoalSort();
|
|
319
|
+
if (!stamp) return '';
|
|
320
|
+
const { label, ms } = stamp(g);
|
|
321
|
+
return ` · ${escapeHtml(label)} ${escapeHtml(ms ? fmtDate(new Date(ms).toISOString()) : '—')}`;
|
|
322
|
+
}
|
|
323
|
+
|
|
266
324
|
function goalRowHtml(g) {
|
|
267
325
|
const d = state.details.get(String(g.id));
|
|
268
326
|
const crit = d ? (d.criteria || []) : [];
|
|
@@ -292,7 +350,7 @@
|
|
|
292
350
|
<div class="gr-ringcol">${ringHtml}</div>
|
|
293
351
|
<div class="gr-main">
|
|
294
352
|
<a class="gr-title" href="${goalHref}">${escapeHtml(g.title || 'Untitled goal')}</a>
|
|
295
|
-
<div class="gr-meta">#${escapeHtml(String(g.id))} · ${escapeHtml(g.version_id || '')}${(achieved || g.status === 'open') ? '' : ' · ' + escapeHtml(g.status)}</div>
|
|
353
|
+
<div class="gr-meta">#${escapeHtml(String(g.id))} · ${escapeHtml(g.version_id || '')}${(achieved || g.status === 'open') ? '' : ' · ' + escapeHtml(g.status)}${goalStampHtml(g)}</div>
|
|
296
354
|
${achieved ? '' : goalRollLine(r)}
|
|
297
355
|
</div>
|
|
298
356
|
<div class="gr-right">${right}</div>
|
|
@@ -323,11 +381,14 @@
|
|
|
323
381
|
const r = rollupOf(g.id);
|
|
324
382
|
return g.status === 'open' && r.ready === 0 && r.active === 0 && r.blocked > 0;
|
|
325
383
|
};
|
|
326
|
-
|
|
327
|
-
|
|
384
|
+
// task 1002965 — the Sort choice orders goals WITHIN each group; the
|
|
385
|
+
// Active / Blocked / Achieved / Archived grouping (task 1002439's momentum
|
|
386
|
+
// layout) is what the page is for and is never re-ordered away.
|
|
387
|
+
const achieved = sortGoals(rows.filter((g) => g.status === 'achieved'));
|
|
388
|
+
const archived = sortGoals(rows.filter((g) => g.status === 'archived'));
|
|
328
389
|
const openRows = rows.filter((g) => g.status !== 'achieved' && g.status !== 'archived');
|
|
329
|
-
const blocked = openRows.filter(isBlocked);
|
|
330
|
-
const active = openRows.filter((g) => !isBlocked(g));
|
|
390
|
+
const blocked = sortGoals(openRows.filter(isBlocked));
|
|
391
|
+
const active = sortGoals(openRows.filter((g) => !isBlocked(g)));
|
|
331
392
|
|
|
332
393
|
let R = 0, A = 0, B = 0;
|
|
333
394
|
openRows.forEach((g) => { const r = rollupOf(g.id); R += r.ready; A += r.active; B += r.blocked; });
|
|
@@ -339,7 +400,16 @@
|
|
|
339
400
|
|
|
340
401
|
const sec = (label, n) => `<div class="gr-sec"><b>${label}</b><span class="gr-sec__n">${n}</span></div>`;
|
|
341
402
|
const listOf = (arr) => `<div class="gr-list">${arr.map(goalRowHtml).join('')}</div>`;
|
|
342
|
-
|
|
403
|
+
// task 1002965 — an achieved/archived goal is a compact pill, so its sort
|
|
404
|
+
// date rides as its own muted span (the row's `gr-meta` equivalent). The
|
|
405
|
+
// pills row already wraps, so the extra text can't widen the page.
|
|
406
|
+
const pillWhen = (g) => {
|
|
407
|
+
const { stamp } = currentGoalSort();
|
|
408
|
+
if (!stamp) return '';
|
|
409
|
+
const { label, ms } = stamp(g);
|
|
410
|
+
return `<span class="gr-pill__when">${escapeHtml(label)} ${escapeHtml(ms ? fmtDate(new Date(ms).toISOString()) : '—')}</span>`;
|
|
411
|
+
};
|
|
412
|
+
const pill = (g) => `<a class="gr-pill" href="#/goal/${escapeHtml(String(g.id))}"><span class="gr-pill__ok">✓</span><b>${escapeHtml(g.title || 'Untitled')}</b><span class="gr-pill__id">#${escapeHtml(String(g.id))}</span>${pillWhen(g)}</a>`;
|
|
343
413
|
|
|
344
414
|
grid.innerHTML = summary
|
|
345
415
|
+ (active.length ? sec('Active', active.length) + listOf(active) : '')
|
|
@@ -1810,17 +1880,34 @@
|
|
|
1810
1880
|
}
|
|
1811
1881
|
|
|
1812
1882
|
// task 1932 — any list filter change re-paginates the grid from page 1.
|
|
1813
|
-
|
|
1883
|
+
// task 1002965 — and the Sort choice is remembered, so a builder who prefers
|
|
1884
|
+
// "recently active" doesn't re-pick it on every visit. localStorage (not the
|
|
1885
|
+
// kit's `url` mode): this page owns the #/goal/N hash route and its own boot
|
|
1886
|
+
// reads it, so keeping the pref off the URL leaves deep links shareable and
|
|
1887
|
+
// unchanged. Private-mode writes throw — swallowed, the pref is a nicety.
|
|
1888
|
+
const onListFilterChange = () => {
|
|
1889
|
+
try { window.localStorage.setItem(GOAL_SORT_PREF, $('#f-goal-order')?.value || 'priority'); } catch (_) { /* private mode */ }
|
|
1890
|
+
pagers.list.page = 1;
|
|
1891
|
+
renderList();
|
|
1892
|
+
};
|
|
1814
1893
|
// task 2171 — the list filter bar is a kit filterBar: Version / Status /
|
|
1815
1894
|
// Membership, all primary (no overflow needed). Control ids are unchanged
|
|
1816
1895
|
// (f-goal-version, f-goal-status, f-goal-mine) so filteredGoals' reads +
|
|
1817
1896
|
// populateVersionFilter's dynamic option fill keep working untouched.
|
|
1897
|
+
// task 1002965 adds Sort (f-goal-order — NOT f-goal-sort, which is already
|
|
1898
|
+
// taken by the goal-detail To-do bar further down this file).
|
|
1899
|
+
let savedGoalSort = null;
|
|
1900
|
+
try { savedGoalSort = window.localStorage.getItem(GOAL_SORT_PREF); } catch (_) { /* private mode */ }
|
|
1818
1901
|
makeFilterBar({
|
|
1819
1902
|
mount: $('#goals-filters'),
|
|
1820
1903
|
primary: [
|
|
1821
1904
|
{ id: 'f-goal-version', label: 'Version', options: [{ value: '', label: '— any —' }] },
|
|
1822
1905
|
{ id: 'f-goal-status', label: 'Status', options: [{ value: '', label: '— any —' }, 'open', 'achieved', 'archived'] },
|
|
1823
1906
|
{ id: 'f-goal-mine', label: 'Membership', options: [{ value: '', label: 'All goals' }, { value: 'mine', label: 'My goals' }] },
|
|
1907
|
+
{ id: 'f-goal-order',
|
|
1908
|
+
label: 'Sort',
|
|
1909
|
+
value: GOAL_SORTS[savedGoalSort] ? savedGoalSort : undefined,
|
|
1910
|
+
options: Object.entries(GOAL_SORTS).map(([value, { label }]) => ({ value, label })) },
|
|
1824
1911
|
],
|
|
1825
1912
|
onChange: onListFilterChange,
|
|
1826
1913
|
});
|
|
@@ -251,6 +251,9 @@ a.goal-card:hover { text-decoration: none; border-color: var(--border-strong); b
|
|
|
251
251
|
.gr-pill b { color: var(--ink); font-weight: 600; }
|
|
252
252
|
.gr-pill__ok { width: 17px; height: 17px; border-radius: 50%; background: var(--ok-weak); color: var(--ok); display: grid; place-items: center; font-size: 10px; font-weight: 800; }
|
|
253
253
|
.gr-pill__id { color: var(--ink-faint); font-variant-numeric: tabular-nums; }
|
|
254
|
+
/* task 1002965 — the date an achieved/archived pill was ordered on, shown only
|
|
255
|
+
while a date sort is active (the pill's counterpart to .gr-meta). */
|
|
256
|
+
.gr-pill__when { color: var(--ink-faint); font-variant-numeric: tabular-nums; }
|
|
254
257
|
.gr-pills--muted { opacity: .6; }
|
|
255
258
|
|
|
256
259
|
@media (max-width: 640px) {
|
|
@@ -4,6 +4,10 @@ const express = require('express');
|
|
|
4
4
|
// sibling — a module never requires a core internal.
|
|
5
5
|
const api = require('../../../src/module-api');
|
|
6
6
|
const inbox = require('../inbox');
|
|
7
|
+
// Full Idea capture (task 1002919 / BV1.R143): the template validator (R141)
|
|
8
|
+
// and the deterministic scorer (R142) — both pure, both walk the declaration.
|
|
9
|
+
const { validateFieldValues } = require('../templates');
|
|
10
|
+
const { completenessScore } = require('../completeness');
|
|
7
11
|
const { validateOrRespond, LIMITS, parseId } = api;
|
|
8
12
|
|
|
9
13
|
const INBOX_STATUS_VALUES = ['open', 'promoted', 'discarded', 'merged'];
|
|
@@ -42,6 +46,12 @@ module.exports = function buildInboxRouter() {
|
|
|
42
46
|
kind: { type: 'string', maxLength: 64 },
|
|
43
47
|
suggested_version: { type: 'string', maxLength: 64 },
|
|
44
48
|
suggested_priority: { type: 'integer', min: 0, max: 100 },
|
|
49
|
+
// Full Idea capture (R143) — ALL OPTIONAL: omitting every one of these is
|
|
50
|
+
// exactly the pre-grade quick capture (tests/idea_quick_guard.mjs pins
|
|
51
|
+
// that title stays the only required field).
|
|
52
|
+
grade: { type: 'string', maxLength: 16 },
|
|
53
|
+
template_key: { type: 'string', maxLength: inbox.TEMPLATE_KEY_MAX },
|
|
54
|
+
field_values: { type: 'object' },
|
|
45
55
|
})) return;
|
|
46
56
|
const body = req.body || {};
|
|
47
57
|
if (body.kind === 'criterion-proposal') {
|
|
@@ -50,6 +60,40 @@ module.exports = function buildInboxRouter() {
|
|
|
50
60
|
return res.fail('rank_forbidden', 403, { required: ['metic', 'archon'], actual: rank, reason: "kind='criterion-proposal' is a planning-session write; Metic+ only." });
|
|
51
61
|
}
|
|
52
62
|
}
|
|
63
|
+
// Full Idea capture (task 1002919 / BV1.R143). Only 'full' means anything
|
|
64
|
+
// at capture: 'quick' is the default (explicit or omitted — no grade row
|
|
65
|
+
// either way, the R140 absence-is-quick design), and 'spark' is REFUSED
|
|
66
|
+
// here because sparking is the author's later, deliberate act on an
|
|
67
|
+
// EXISTING quick idea (R144's surface) — an idea does not arrive pre-marked.
|
|
68
|
+
const grade = body.grade ?? null;
|
|
69
|
+
if (grade !== null && grade !== 'quick' && grade !== 'full') {
|
|
70
|
+
return res.fail('bad_grade_at_capture', 400, {
|
|
71
|
+
grade, allowed: ['quick', 'full'],
|
|
72
|
+
reason: "capture accepts grade 'quick' (the default) or 'full'; a spark is made from an existing idea, not captured as one.",
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
const templateKey = body.template_key ?? undefined;
|
|
76
|
+
if (grade === 'full') {
|
|
77
|
+
// Validate BEFORE any write, so a bad payload is a named 400 and no
|
|
78
|
+
// orphan idea lands. Each error carries { field, reason } — the
|
|
79
|
+
// "clear error naming the missing field" the task's done-when demands.
|
|
80
|
+
//
|
|
81
|
+
// WRAPPED IN try/catch as defence in depth. templateFor's own-property
|
|
82
|
+
// guard already keeps validateFieldValues from throwing on a hostile
|
|
83
|
+
// template_key (the R143 grader's blocker), but this validation runs
|
|
84
|
+
// OUTSIDE the capture try/catch below, so a future consumer that
|
|
85
|
+
// reintroduces a throw would again escape as an unhandled rejection and
|
|
86
|
+
// crash the process. Catching here means the boundary holds regardless:
|
|
87
|
+
// a validator that throws is a 400, never a downed server.
|
|
88
|
+
let v;
|
|
89
|
+
try {
|
|
90
|
+
v = validateFieldValues(body.field_values ?? {}, templateKey ? { templateKey } : undefined);
|
|
91
|
+
} catch (validationErr) {
|
|
92
|
+
console.error('[gds] POST /inbox field-values validation threw:', validationErr);
|
|
93
|
+
return res.fail('invalid_field_values', 400, { errors: [{ field: null, reason: 'unvalidatable' }], template_key: templateKey ?? null });
|
|
94
|
+
}
|
|
95
|
+
if (!v.ok) return res.fail('invalid_field_values', 400, { errors: v.errors, template_key: templateKey ?? null });
|
|
96
|
+
}
|
|
53
97
|
try {
|
|
54
98
|
const idea = await inbox.captureIdea({
|
|
55
99
|
title: body.title,
|
|
@@ -59,8 +103,30 @@ module.exports = function buildInboxRouter() {
|
|
|
59
103
|
suggestedPriority: body.suggested_priority ?? null,
|
|
60
104
|
capturedBy: req.builder.id,
|
|
61
105
|
});
|
|
106
|
+
let grade_warning;
|
|
107
|
+
if (grade === 'full') {
|
|
108
|
+
// The idea is captured; from here every failure is FAIL-OPEN (the
|
|
109
|
+
// task's contract: a scoring/grading hiccup never blocks capture —
|
|
110
|
+
// the idea still lands, as quick, with a named warning).
|
|
111
|
+
try {
|
|
112
|
+
// completenessScore never throws (R142's contract) — null = unscorable.
|
|
113
|
+
const row = await inbox.setIdeaGrade(idea.id, {
|
|
114
|
+
grade: 'full',
|
|
115
|
+
...(templateKey ? { templateKey } : {}),
|
|
116
|
+
fieldValues: body.field_values,
|
|
117
|
+
completenessScore: completenessScore(body.field_values, templateKey ? { templateKey } : undefined),
|
|
118
|
+
});
|
|
119
|
+
idea.grade = row.grade;
|
|
120
|
+
idea.template_key = row.template_key;
|
|
121
|
+
idea.field_values = row.field_values;
|
|
122
|
+
idea.completeness_score = row.completeness_score;
|
|
123
|
+
} catch (gradeErr) {
|
|
124
|
+
console.error('[gds] POST /inbox full-grade write failed (idea captured, landing as quick):', gradeErr);
|
|
125
|
+
grade_warning = 'captured_but_ungraded';
|
|
126
|
+
}
|
|
127
|
+
}
|
|
62
128
|
const open_count = await inbox.countOpenIdeas({ viewerRank: req.builder?.rank ?? null });
|
|
63
|
-
res.status(201).json({ idea, open_count, kind_auto: !body.kind });
|
|
129
|
+
res.status(201).json({ idea, open_count, kind_auto: !body.kind, ...(grade_warning ? { grade_warning } : {}) });
|
|
64
130
|
} catch (err) {
|
|
65
131
|
if (err && err.code) {
|
|
66
132
|
const status =
|
|
@@ -74,7 +74,21 @@ if (BASE_TEMPLATE.key !== DEFAULT_TEMPLATE_KEY) {
|
|
|
74
74
|
// whether that is a 400 (capture) or a render-as-raw fallback (display).
|
|
75
75
|
function templateFor(key, registry = BUILTIN_TEMPLATES) {
|
|
76
76
|
if (typeof key !== 'string' || !key) return null;
|
|
77
|
-
|
|
77
|
+
// OWN-PROPERTY LOOKUP, never a bare `registry[key]`. `template_key` is
|
|
78
|
+
// attacker-controlled at capture (POST /inbox, R143) and only length-checked,
|
|
79
|
+
// so a key like 'constructor' / '__proto__' / 'toString' would otherwise
|
|
80
|
+
// resolve up the prototype chain to an Object.prototype member — a truthy
|
|
81
|
+
// non-template whose `.fields` is undefined, and fieldOrder(template) then
|
|
82
|
+
// throws a synchronous TypeError from inside the pure validator, OUTSIDE the
|
|
83
|
+
// route's try/catch → an unhandled rejection that crashes the process. One
|
|
84
|
+
// authenticated request, any rank. Object.hasOwn confines resolution to keys
|
|
85
|
+
// the registry actually declares. (Found by the R143 grader.)
|
|
86
|
+
if (!Object.hasOwn(registry, key)) return null;
|
|
87
|
+
const t = registry[key];
|
|
88
|
+
// Defence in depth: even an own key must resolve to a real template shape
|
|
89
|
+
// before any consumer reads `.fields`. A malformed entry is a null, never a
|
|
90
|
+
// throw.
|
|
91
|
+
return t && Array.isArray(t.fields) ? t : null;
|
|
78
92
|
}
|
|
79
93
|
|
|
80
94
|
// The one legal source of field display order (see header). Returns key order
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.165",
|
|
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.165",
|
|
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.165",
|
|
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",
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
// Seed the privacy-model + invite-inbox build tasks the 1002785 spike scoped,
|
|
2
|
+
// under goal 1000045 "Public builder profiles" (BONGOS-V1, internal).
|
|
3
|
+
//
|
|
4
|
+
// Source of truth: docs/specs/bongos-v1-privacy-and-invites.md (owner-signed
|
|
5
|
+
// 2026-08-16 on the sliver + expiry decisions). Idempotent on source_ref
|
|
6
|
+
// bv1-privacy-PVnn; safe to re-run. PV4 stays backlog deliberately — its
|
|
7
|
+
// instance-invite half attaches to the console wave's R2/R6 (spike 1002786),
|
|
8
|
+
// which seeds into its own goal; only PV6 is promoted (fully independent).
|
|
9
|
+
//
|
|
10
|
+
// Run: node scripts/gds/seed-bv1-privacy-invites-tasks.js
|
|
11
|
+
|
|
12
|
+
const { apiCall } = require('./cli-lib');
|
|
13
|
+
|
|
14
|
+
const VER = 'BONGOS-V1';
|
|
15
|
+
const GOAL = 1000045;
|
|
16
|
+
const SRC = 'planning-session';
|
|
17
|
+
const SPEC = 'docs/specs/bongos-v1-privacy-and-invites.md';
|
|
18
|
+
|
|
19
|
+
// Existing tasks referenced by id (seed-bv1-profiles-tasks.js).
|
|
20
|
+
const R02 = 1002278, R03 = 1002279;
|
|
21
|
+
|
|
22
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
23
|
+
|
|
24
|
+
// Throttled + 429-aware wrapper (same shape as the sibling seeders).
|
|
25
|
+
async function api(method, path, body, _tries = 0) {
|
|
26
|
+
await sleep(1500);
|
|
27
|
+
const r = await apiCall(method, `/api/gds${path}`, body);
|
|
28
|
+
if (r.status === 429 && _tries < 8) {
|
|
29
|
+
const wait = (r.data?.error?.details?.retry_after_seconds || 30) + 1;
|
|
30
|
+
console.log(` …rate-limited, waiting ${wait}s`);
|
|
31
|
+
await sleep(wait * 1000);
|
|
32
|
+
return api(method, path, body, _tries + 1);
|
|
33
|
+
}
|
|
34
|
+
return r;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const NEW_TASKS = [
|
|
38
|
+
{
|
|
39
|
+
ref: 'PV1', kind: 'feature', prio: 3, est: 120, crit: ['C2'], deps: [R03],
|
|
40
|
+
title: 'Privacy flags: account_visibility + recruiter_discoverable on platform accounts',
|
|
41
|
+
desc: `Add account_visibility ('public'|'private', default 'public') and recruiter_discoverable (boolean NULL — tri-state; effective = COALESCE(flag, visibility='public')) to platform_identity_accounts via an additive platform_identity_* migration. Extend the own-scoped PATCH write path task ${R03} (R03) establishes for hide_stats to cover both, and add the two settings rows to the profile rail (coordinate P04, task 1002800). The discoverable toggle's copy must state exactly what it grants: the D3 sliver (name, disciplines, active-recently), name-only if stats are hidden. Model + defaults: ${SPEC} D1.`,
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
ref: 'PV2', kind: 'feature', prio: 3, est: 180, crit: ['C1', 'C2'], deps: [R02],
|
|
45
|
+
title: 'Effective-visibility enforcement: one shared predicate across every public read',
|
|
46
|
+
desc: `One helper (SQL predicate + port function, the ADR 0171 D2 "enforced next to the data" pattern) that every public surface uses: /u/<handle> returns 404 (never 403) for private accounts TO EVERYONE INCLUDING SELF on the public read (the page renders self-view via the own-scope side door instead — D2's viewer-independence reconciliation); private accounts absent from search, leaderboards, sitemap; the rollup predicate extends profile_state='active' AND NOT hide_stats with visibility. No surface writes its own variant — drift here is a privacy leak. Matrix: ${SPEC} D2. Also depends on PV1 (bv1-privacy-PV1).`,
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
ref: 'PV3', kind: 'feature', prio: 3, est: 120, crit: ['C2', 'C5'], deps: [],
|
|
50
|
+
title: 'getRecruiterSliver port + recruiting-surface gating (the pre-accept boundary)',
|
|
51
|
+
desc: `The owner-signed D3 sliver: handle, display name, avatar, disciplines union, active-in-last-30-days boolean — nothing else, name-only when hide_stats is true, never for provisional accounts, never a profile-page link. Authorization-scoped port getRecruiterSliver(githubId) beside getPublicProfileExtras, whitelist-shaped inside platform-identity, its own planted-sentinel leak test (tests/profile_route.mjs pattern), route-gated by project.recruit (the console wave's atom — coordinate spike 1002786 R3). Depends on PV1+PV2 (bv1-privacy-PV1/PV2). Boundary: ${SPEC} D3.`,
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
ref: 'PV4', kind: 'feature', prio: 3, est: 120, crit: ['C3'], deps: [],
|
|
55
|
+
title: 'Invite lifecycle: 30-day read-time expiry, seen_at, decline closes the door',
|
|
56
|
+
desc: `Owner-signed: a recruit invite's pre-approved sign-in door expires 30 days after creation, enforced READ-TIME (the sign-in gate's invited-check + every queue view compute created_at + 30 days; no sweeper). seen_at lands on the hub notification row (set on first inbox render) and on goal_membership_requests for parity (goal invites get NO expiry — not an admission door). Decline in the inbox relays a door-close to the instance (invite row → dismissed) so no declined invite leaves a live door. COORDINATION: the invite storage itself is the console wave's R2 (access_requests kind='invite') and rescind-cleans-both-sides is its R6 (spike 1002786) — this task lands the states on whatever R2/R6 ship, which is why it stays backlog until that wave seeds. Lifecycle: ${SPEC} D4.`,
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
ref: 'PV5', kind: 'feature', prio: 3, est: 90, crit: ['C3'], deps: [],
|
|
60
|
+
title: 'Hub inbox project slice: recruit invites in the attention card',
|
|
61
|
+
desc: `A project slice in the EXISTING attention card (goal-inbox.js slice pattern + window.OTBAttention.refresh()), on the hub hall + apex home, fed by the existing GET /my-projects/invites. Accept = "open <project> — sign in" link (clicking through IS acceptance, console D2); Decline = the PV4 door-close path. No parallel inbox; federated instances' halls link out only (V1). Notification path IS the inbox — SSE/email explicitly deferred. Depends on PV4 (bv1-privacy-PV4). Shape: ${SPEC} D5.`,
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
ref: 'PV6', kind: 'feature', prio: 4, est: 90, crit: [], deps: [], ready: true,
|
|
65
|
+
title: 'Private goals: accepting_requests toggle + owner rescind-pending-invite route',
|
|
66
|
+
desc: `goals.accepting_requests boolean default true — a private goal with it false refuses join-requests with honest copy ("this goal isn't taking requests right now"); invitations unaffected; UI shows the toggle only on private goals. Plus the missing half of core_188's 'cancelled' state: an owner/manager route to rescind a PENDING invitation (only requester-withdraw exists today — modules/lifecycle/routes/goals.js). Design: ${SPEC} D6.`,
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
ref: 'PV7', kind: 'feature', prio: 3, est: 90, crit: ['C4'], deps: [],
|
|
70
|
+
title: 'Applications carry the applicant profile: note through the relay, live render in the queue',
|
|
71
|
+
desc: `One rule, no new disclosure class: the reviewer sees what the applicant's profile rules would show them + the applicant's note, evaluated LIVE at review time (never a snapshot — hide takes effect on next render, C2). Public account → public profile view; private account → the D3 sliver (the apply UI states "your application shares: …"). Plumbing: POST /my-projects/join gains an applicant-supplied note (capped ~500 chars, sanitized; replaces the hardcoded 'via platform My Projects'), and the reviewer queue (console R4's page — spike 1002786) resolves the profile from the hub at render. Depends on PV3 (bv1-privacy-PV3). Rule: ${SPEC} D7.`,
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
ref: 'PV8', kind: 'verify', prio: 3, est: 60, crit: ['C2'], deps: [],
|
|
75
|
+
title: 'PROOF: the privacy matrix as a test',
|
|
76
|
+
desc: `The D2 matrix, asserted: a private account's /u/<handle> public read 404s to a stranger AND to its own authenticated session (the page self-view comes from the own-scope endpoint — assert both halves, this is P10 task 1002806 sharpened); hidden stats never appear in any response (planted sentinels); the recruiter sliver is exactly D3's shape (leak test on the port); an expired invite refuses sign-in; a declined invite's door is closed instance-side. Depends on PV2+PV3+PV4 (bv1-privacy-PV2/PV3/PV4). Matrix: ${SPEC} D2.`,
|
|
77
|
+
},
|
|
78
|
+
];
|
|
79
|
+
|
|
80
|
+
(async () => {
|
|
81
|
+
console.log(`Seeding privacy+invites tasks under goal ${GOAL} (${VER})…`);
|
|
82
|
+
const idByRef = {};
|
|
83
|
+
let created = 0, existing = 0;
|
|
84
|
+
|
|
85
|
+
for (const t of NEW_TASKS) {
|
|
86
|
+
const r = await api('POST', '/tasks', {
|
|
87
|
+
version_id: VER,
|
|
88
|
+
goal_id: GOAL,
|
|
89
|
+
title: t.title,
|
|
90
|
+
description: t.desc,
|
|
91
|
+
kind: t.kind,
|
|
92
|
+
discipline: 'engineer',
|
|
93
|
+
priority: t.prio,
|
|
94
|
+
est_minutes: t.est,
|
|
95
|
+
criterion_ids: t.crit,
|
|
96
|
+
source: SRC,
|
|
97
|
+
source_ref: `bv1-privacy-${t.ref}`,
|
|
98
|
+
});
|
|
99
|
+
if (!r.ok) { console.log(` ${t.ref}: FAILED ${r.status} ${JSON.stringify(r.data)}`); continue; }
|
|
100
|
+
const task = r.data.task || r.data;
|
|
101
|
+
idByRef[t.ref] = Number(task.id);
|
|
102
|
+
if (r.data.idempotent || r.data.existing) existing++; else created++;
|
|
103
|
+
console.log(` ${t.ref} → task ${task.id} (${t.kind}, P${t.prio})`);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
console.log('Wiring dependencies…');
|
|
107
|
+
const DEP_SETS = [
|
|
108
|
+
['PV1', [R03]],
|
|
109
|
+
['PV2', [R02, idByRef.PV1]],
|
|
110
|
+
['PV3', [idByRef.PV1, idByRef.PV2]],
|
|
111
|
+
['PV5', [idByRef.PV4]],
|
|
112
|
+
['PV7', [idByRef.PV3]],
|
|
113
|
+
['PV8', [idByRef.PV2, idByRef.PV3, idByRef.PV4]],
|
|
114
|
+
];
|
|
115
|
+
for (const [ref, deps] of DEP_SETS) {
|
|
116
|
+
const id = idByRef[ref];
|
|
117
|
+
const clean = deps.filter(Boolean);
|
|
118
|
+
if (!id || !clean.length) continue;
|
|
119
|
+
const r = await api('PUT', `/tasks/${id}/dependencies`, { depends_on_task_ids: clean });
|
|
120
|
+
console.log(` ${ref} (task ${id}) depends on [${clean.join(', ')}]: ${r.ok ? 'ok' : 'FAILED ' + r.status + ' ' + JSON.stringify(r.data).slice(0, 160)}`);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
console.log('Promoting the independent root…');
|
|
124
|
+
for (const t of NEW_TASKS.filter((x) => x.ready)) {
|
|
125
|
+
const id = idByRef[t.ref];
|
|
126
|
+
if (!id) continue;
|
|
127
|
+
const r = await api('POST', `/tasks/${id}/promote`, {});
|
|
128
|
+
console.log(` ${t.ref} (task ${id}): ${r.ok ? 'ready' : 'promote note ' + JSON.stringify(r.data).slice(0, 120)}`);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
console.log(`\nDONE. created=${created} existing=${existing}.`);
|
|
132
|
+
console.log('ref→id:', JSON.stringify(idByRef));
|
|
133
|
+
})();
|
package/src/module-api.js
CHANGED
|
@@ -753,8 +753,12 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
|
|
|
753
753
|
// landed since 1.19.161 with no explicit bump. run 31959496815. (task 1002620)
|
|
754
754
|
// 1.19.163 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
755
755
|
// landed since 1.19.162 with no explicit bump. run 31959864781. (task 1002620)
|
|
756
|
+
// 1.19.164 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
757
|
+
// landed since 1.19.163 with no explicit bump. run 31960518012. (task 1002620)
|
|
758
|
+
// 1.19.165 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
759
|
+
// landed since 1.19.164 with no explicit bump. run 31960929146. (task 1002620)
|
|
756
760
|
// ---------------------------------------------------------------------------
|
|
757
|
-
const CORE_VERSION = '1.19.
|
|
761
|
+
const CORE_VERSION = '1.19.165'; // CI auto-patch carrier (ADR 0161); see changelog above
|
|
758
762
|
|
|
759
763
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
760
764
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
// tests/idea_full_capture.mjs
|
|
2
|
+
//
|
|
3
|
+
// task 1002919 (BV1.R143, goal 1000062) — POST /inbox accepts a Full Idea.
|
|
4
|
+
//
|
|
5
|
+
// The done-when, as assertions: a Full Idea round-trips with its fields and
|
|
6
|
+
// score intact; an invalid field set is a 400 NAMING the field, refused BEFORE
|
|
7
|
+
// any write; a grade-write failure never blocks capture (fail-open: the idea
|
|
8
|
+
// lands as quick with a named warning); 'spark' is refused at capture (sparking
|
|
9
|
+
// is R144's act on an EXISTING idea); and the quick path stays byte-identical
|
|
10
|
+
// (the deep version of that lives in tests/idea_quick_guard.mjs).
|
|
11
|
+
//
|
|
12
|
+
// DB-free the idea_grades.mjs way (pool.query patched on the doorway before the
|
|
13
|
+
// modules load), driving the REAL route handler through a stub req/res — the
|
|
14
|
+
// same validator, the same error map a client sees.
|
|
15
|
+
//
|
|
16
|
+
// Run: node tests/idea_full_capture.mjs
|
|
17
|
+
|
|
18
|
+
import { strict as assert } from 'node:assert';
|
|
19
|
+
import { createRequire } from 'node:module';
|
|
20
|
+
|
|
21
|
+
const require = createRequire(import.meta.url);
|
|
22
|
+
const api = require('../src/module-api.js');
|
|
23
|
+
|
|
24
|
+
let queries = [];
|
|
25
|
+
let failNextGradeWrite = false;
|
|
26
|
+
api.pool.query = async (sql, params = []) => {
|
|
27
|
+
queries.push({ sql, params });
|
|
28
|
+
if (/INSERT INTO ideas_grades/i.test(sql)) {
|
|
29
|
+
if (failNextGradeWrite) { failNextGradeWrite = false; throw new Error('synthetic grade-write outage'); }
|
|
30
|
+
return { rows: [{ idea_id: params[0], grade: params[1], template_key: params[2], field_values: JSON.parse(params[3]), completeness_score: params[4] }] };
|
|
31
|
+
}
|
|
32
|
+
if (/INSERT INTO idea_inbox/i.test(sql)) {
|
|
33
|
+
return { rows: [{ id: '77', title: params[0], status: 'open' }] };
|
|
34
|
+
}
|
|
35
|
+
if (/SELECT COUNT/i.test(sql) || /count/i.test(sql)) return { rows: [{ n: '3', count: '3' }] };
|
|
36
|
+
return { rows: [] };
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const { BASE_TEMPLATE } = require('../modules/ideas/templates.js');
|
|
40
|
+
const routesFactory = require('../modules/ideas/routes/inbox.js');
|
|
41
|
+
|
|
42
|
+
// Pull the real POST /inbox handler off the mounted router.
|
|
43
|
+
const router = routesFactory({});
|
|
44
|
+
const layer = router.stack.find((l) => l.route && l.route.path === '/inbox' && l.route.methods.post);
|
|
45
|
+
const handlers = layer.route.stack.map((s) => s.handle);
|
|
46
|
+
const postInbox = handlers[handlers.length - 1]; // after requireBuilder
|
|
47
|
+
|
|
48
|
+
function call(bodyIn) {
|
|
49
|
+
return new Promise((resolve) => {
|
|
50
|
+
const req = { body: bodyIn, builder: { id: '9', rank: 'metic' }, query: {} };
|
|
51
|
+
const res = {
|
|
52
|
+
statusCode: 200,
|
|
53
|
+
status(c) { this.statusCode = c; return this; },
|
|
54
|
+
json(payload) { resolve({ status: this.statusCode, payload }); },
|
|
55
|
+
fail(code, status, extra) {
|
|
56
|
+
const s = typeof status === 'number' ? status : (status && status.status) || 500;
|
|
57
|
+
resolve({ status: s, payload: { error: code, ...(extra || {}) } });
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
postInbox(req, res);
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const FULL = Object.fromEntries(
|
|
65
|
+
BASE_TEMPLATE.fields.map((f) => [f.key, `A substantive answer for ${f.key}, long enough to clear the floor.`]),
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
let passed = 0;
|
|
69
|
+
let failed = 0;
|
|
70
|
+
async function test(name, fn) {
|
|
71
|
+
queries = [];
|
|
72
|
+
try { await fn(); passed++; console.log(` ok ${name}`); }
|
|
73
|
+
catch (err) { failed++; console.error(` FAIL ${name}\n ${err.message}`); }
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
await test('a Full Idea round-trips: 201 with grade, fields and score intact', async () => {
|
|
77
|
+
const r = await call({ title: 'a full idea', grade: 'full', field_values: FULL });
|
|
78
|
+
assert.equal(r.status, 201);
|
|
79
|
+
assert.equal(r.payload.idea.grade, 'full');
|
|
80
|
+
assert.deepEqual(r.payload.idea.field_values, FULL);
|
|
81
|
+
assert.equal(r.payload.idea.completeness_score, 100, 'scored via R142 at capture');
|
|
82
|
+
assert.ok(queries.some((q) => /INSERT INTO idea_inbox/i.test(q.sql)));
|
|
83
|
+
assert.ok(queries.some((q) => /INSERT INTO ideas_grades/i.test(q.sql)));
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
await test('an invalid field set is a 400 naming the field — and NOTHING is written', async () => {
|
|
87
|
+
const r = await call({ title: 'bad full', grade: 'full', field_values: { ...FULL, rogue: 'x' } });
|
|
88
|
+
assert.equal(r.status, 400);
|
|
89
|
+
assert.equal(r.payload.error, 'invalid_field_values');
|
|
90
|
+
assert.deepEqual(r.payload.errors, [{ field: 'rogue', reason: 'unknown_field' }]);
|
|
91
|
+
assert.equal(queries.length, 0, 'refused before any write — no orphan idea');
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
await test('a missing required field is named the same way', async () => {
|
|
95
|
+
const { fit, ...four } = FULL;
|
|
96
|
+
const r = await call({ title: 'incomplete full', grade: 'full', field_values: four });
|
|
97
|
+
assert.equal(r.status, 400);
|
|
98
|
+
assert.ok(r.payload.errors.some((e) => e.field === 'fit' && e.reason === 'missing_required'));
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
await test("'spark' is refused at capture — sparking is an act on an existing idea (R144)", async () => {
|
|
102
|
+
const r = await call({ title: 'pre-marked?', grade: 'spark' });
|
|
103
|
+
assert.equal(r.status, 400);
|
|
104
|
+
assert.equal(r.payload.error, 'bad_grade_at_capture');
|
|
105
|
+
assert.deepEqual(r.payload.allowed, ['quick', 'full']);
|
|
106
|
+
assert.equal(queries.length, 0);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
await test('a grade-write failure never blocks capture: idea lands, named warning returned', async () => {
|
|
110
|
+
failNextGradeWrite = true;
|
|
111
|
+
const r = await call({ title: 'resilient full', grade: 'full', field_values: FULL });
|
|
112
|
+
assert.equal(r.status, 201, 'the capture still succeeds');
|
|
113
|
+
assert.equal(r.payload.grade_warning, 'captured_but_ungraded');
|
|
114
|
+
assert.ok(queries.some((q) => /INSERT INTO idea_inbox/i.test(q.sql)), 'the idea landed');
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
await test('the quick path is untouched: a bare capture writes idea_inbox only', async () => {
|
|
118
|
+
const r = await call({ title: 'just a thought' });
|
|
119
|
+
assert.equal(r.status, 201);
|
|
120
|
+
// since R140 every new idea reads grade='quick' BY DEFINITION (the absence
|
|
121
|
+
// design) — what the guard forbids is a grade-table statement, not the label
|
|
122
|
+
assert.equal(r.payload.idea.grade, 'quick');
|
|
123
|
+
assert.ok(!queries.some((q) => /ideas_grades/i.test(q.sql)), 'zero grade statements');
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
await test("an explicit grade:'quick' is the same no-op quick capture", async () => {
|
|
127
|
+
const r = await call({ title: 'explicitly quick', grade: 'quick' });
|
|
128
|
+
assert.equal(r.status, 201);
|
|
129
|
+
assert.ok(!queries.some((q) => /ideas_grades/i.test(q.sql)));
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
await test('SECURITY (R143 grader blocker): a hostile template_key is a 400, the process survives', async () => {
|
|
133
|
+
// The exact crash the grader found: template_key:'constructor' with grade:full.
|
|
134
|
+
// Pre-fix this threw a synchronous TypeError out of the async handler (an
|
|
135
|
+
// unhandled rejection → process down). Now it is a clean 400 and NOTHING is
|
|
136
|
+
// written. Proven at the route layer, over the real handler.
|
|
137
|
+
for (const hostile of ['constructor', '__proto__', 'toString']) {
|
|
138
|
+
const r = await call({ title: 'attack', grade: 'full', field_values: FULL, template_key: hostile });
|
|
139
|
+
assert.equal(r.status, 400, `template_key='${hostile}' must be a 400, not a crash`);
|
|
140
|
+
assert.equal(r.payload.error, 'invalid_field_values');
|
|
141
|
+
assert.equal(queries.length, 0, 'refused before any write');
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
console.log(`\nidea_full_capture: ${passed} passed, ${failed} failed`);
|
|
146
|
+
if (failed > 0) process.exit(1);
|
|
@@ -13,9 +13,10 @@
|
|
|
13
13
|
// title is the only requirement, and its INSERT touches idea_inbox alone:
|
|
14
14
|
// ZERO statements against ideas_grades. A quick idea is quick because no
|
|
15
15
|
// grade row exists (the R140 design), not because one was written.
|
|
16
|
-
// 2. THE ROUTE SCHEMA —
|
|
17
|
-
// field
|
|
18
|
-
//
|
|
16
|
+
// 2. THE ROUTE SCHEMA — the five pre-grade keys survive and title stays the
|
|
17
|
+
// ONLY required field. (R143 later added grade-era keys as OPTIONALS —
|
|
18
|
+
// the promise was never "the schema can't grow", it is "no new REQUIRED
|
|
19
|
+
// field appears on the quick path".)
|
|
19
20
|
// 3. THE READ — an idea with no grade row reads back grade='quick' with null
|
|
20
21
|
// template/score, via the LEFT JOIN + COALESCE (idea_grades.mjs owns the
|
|
21
22
|
// deep version of this; here it is asserted once as part of the contract).
|
|
@@ -97,15 +98,18 @@ await test('capture: the title requirement is the SAME refusal it always was', a
|
|
|
97
98
|
|
|
98
99
|
// ── 2. the route schema ─────────────────────────────────────────────────────
|
|
99
100
|
|
|
100
|
-
await test('POST /inbox
|
|
101
|
+
await test('POST /inbox: the pre-grade fields survive and title stays the ONLY requirement', () => {
|
|
102
|
+
// R143 legitimately added grade / template_key / field_values as OPTIONAL
|
|
103
|
+
// keys — the promise this guard protects (R150's done-when) was never "the
|
|
104
|
+
// schema can't grow", it is "no new REQUIRED field appears on the quick
|
|
105
|
+
// path". So the pin is: the five original keys remain, and required is
|
|
106
|
+
// exactly ['title'] — a grade-era key turning required fails here by name.
|
|
101
107
|
const src = fs.readFileSync(path.join(__dirname, '..', 'modules', 'ideas', 'routes', 'inbox.js'), 'utf8');
|
|
102
108
|
const post = src.slice(src.indexOf("router.post('/inbox'"), src.indexOf('captureIdea('));
|
|
103
109
|
const schema = post.slice(post.indexOf('{', post.indexOf('validateOrRespond')), post.indexOf('})') + 1);
|
|
104
110
|
for (const key of ['title', 'body_md', 'kind', 'suggested_version', 'suggested_priority']) {
|
|
105
111
|
assert.ok(schema.includes(key), `the quick path still accepts ${key}`);
|
|
106
112
|
}
|
|
107
|
-
assert.doesNotMatch(schema, /grade|template|field_values|completeness/,
|
|
108
|
-
'no grade-era key in the quick capture schema');
|
|
109
113
|
const required = [...schema.matchAll(/(\w+):\s*\{[^}]*required:\s*true/g)].map((m) => m[1]);
|
|
110
114
|
assert.deepEqual(required, ['title'], 'title is the ONLY required field, as it always was');
|
|
111
115
|
});
|
package/tests/idea_templates.mjs
CHANGED
|
@@ -62,6 +62,22 @@ test('templateFor: base key resolves, unknown/blank/non-string do not', () => {
|
|
|
62
62
|
assert.equal(T.templateFor(null), null);
|
|
63
63
|
});
|
|
64
64
|
|
|
65
|
+
test('SECURITY (R143 grader blocker): a prototype-chain key resolves to null, never a crash', () => {
|
|
66
|
+
// template_key is attacker-controlled at capture and only length-checked, so
|
|
67
|
+
// a bare registry[key] would resolve 'constructor' / '__proto__' / 'toString'
|
|
68
|
+
// up the prototype chain to a truthy Object.prototype member — a non-template
|
|
69
|
+
// whose .fields is undefined — and validateFieldValues would then throw a
|
|
70
|
+
// synchronous TypeError, crashing the process. Own-property lookup returns
|
|
71
|
+
// null for every one of these, and the validator path stays a clean refusal.
|
|
72
|
+
for (const hostile of ['constructor', '__proto__', 'toString', 'valueOf', 'hasOwnProperty', 'isPrototypeOf']) {
|
|
73
|
+
assert.equal(T.templateFor(hostile), null, `templateFor('${hostile}') must be null`);
|
|
74
|
+
assert.doesNotThrow(() => T.validateFieldValues({}, { templateKey: hostile }),
|
|
75
|
+
`validateFieldValues must not throw on template_key='${hostile}'`);
|
|
76
|
+
assert.deepEqual(T.validateFieldValues({}, { templateKey: hostile }).errors,
|
|
77
|
+
[{ field: null, reason: 'unknown_template' }]);
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
|
|
65
81
|
// ── validation follows the declaration ──────────────────────────────────────
|
|
66
82
|
|
|
67
83
|
test('a complete five-field payload validates', () => {
|