@bongos/core 1.19.569 → 1.19.570
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 +23 -18
- package/docs/module-api-changelog.md +2 -0
- package/modules/copy-desk/routes/copy-desk.js +4 -0
- package/modules/lifecycle/cascade.js +11 -0
- package/modules/lifecycle/db-tasks.js +37 -7
- package/modules/lifecycle/kickoff-seed.js +5 -0
- package/modules/lifecycle/routes/task-write-routes.js +12 -0
- package/modules/onboarding/newcomer-restock.js +4 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/src/module-api.js +1 -1
- package/tests/reward_gate.mjs +4 -4
- package/tests/task_goal_required.mjs +223 -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.570",
|
|
6
|
+
"core_contract": "1.19.570",
|
|
7
|
+
"source_commit": "7f686cc8bf351a08ac85f77eea54525fa067509b",
|
|
8
8
|
"source_ref": "HEAD",
|
|
9
|
-
"built_at": "2026-09-
|
|
9
|
+
"built_at": "2026-09-06T21:14:46.455Z",
|
|
10
10
|
"redaction": {
|
|
11
11
|
"model": "docs-redacted+functional-verbatim",
|
|
12
12
|
"docs_redacted": 449,
|
|
13
13
|
"agent_docs_stubbed": 24,
|
|
14
|
-
"functional_verbatim":
|
|
14
|
+
"functional_verbatim": 2039,
|
|
15
15
|
"rules": 3,
|
|
16
16
|
"gate_literals": 3,
|
|
17
17
|
"gate": "passed"
|
|
18
18
|
},
|
|
19
|
-
"file_count":
|
|
20
|
-
"tree_sha256": "
|
|
19
|
+
"file_count": 2512,
|
|
20
|
+
"tree_sha256": "e0096a8f2ab3ba5363c72d452787c803202d6f43c78964e74a36a079ff086f2e",
|
|
21
21
|
"files": [
|
|
22
22
|
{
|
|
23
23
|
"path": ".claude/skills/blocker-review/SKILL.md",
|
|
@@ -2687,7 +2687,7 @@
|
|
|
2687
2687
|
{
|
|
2688
2688
|
"path": "docs/module-api-changelog.md",
|
|
2689
2689
|
"mode": "0000644",
|
|
2690
|
-
"sha256": "
|
|
2690
|
+
"sha256": "bfa90a0b7ff77b9befa37207824797d0268260fd83f960fb8e2f5ff52bf97e63"
|
|
2691
2691
|
},
|
|
2692
2692
|
{
|
|
2693
2693
|
"path": "docs/modules-contract.md",
|
|
@@ -3842,7 +3842,7 @@
|
|
|
3842
3842
|
{
|
|
3843
3843
|
"path": "modules/copy-desk/routes/copy-desk.js",
|
|
3844
3844
|
"mode": "0000644",
|
|
3845
|
-
"sha256": "
|
|
3845
|
+
"sha256": "63ed245f47d15758ef0926a489ad1fc7ef56d8539e836ed9e319e09b99a4a38e"
|
|
3846
3846
|
},
|
|
3847
3847
|
{
|
|
3848
3848
|
"path": "modules/copy-desk/tests/copy_flags.mjs",
|
|
@@ -5407,7 +5407,7 @@
|
|
|
5407
5407
|
{
|
|
5408
5408
|
"path": "modules/lifecycle/cascade.js",
|
|
5409
5409
|
"mode": "0000644",
|
|
5410
|
-
"sha256": "
|
|
5410
|
+
"sha256": "d609ecb540af92f84e365fc7c4233b0b73517665939bcffe21d068de04167a6d"
|
|
5411
5411
|
},
|
|
5412
5412
|
{
|
|
5413
5413
|
"path": "modules/lifecycle/claim-eligibility.js",
|
|
@@ -5492,7 +5492,7 @@
|
|
|
5492
5492
|
{
|
|
5493
5493
|
"path": "modules/lifecycle/db-tasks.js",
|
|
5494
5494
|
"mode": "0000644",
|
|
5495
|
-
"sha256": "
|
|
5495
|
+
"sha256": "e978cadb879a57d8d7e3bb84657a787ac7e83125666fa2d31013ac64b37b0d7a"
|
|
5496
5496
|
},
|
|
5497
5497
|
{
|
|
5498
5498
|
"path": "modules/lifecycle/db-versions.js",
|
|
@@ -5577,7 +5577,7 @@
|
|
|
5577
5577
|
{
|
|
5578
5578
|
"path": "modules/lifecycle/kickoff-seed.js",
|
|
5579
5579
|
"mode": "0000644",
|
|
5580
|
-
"sha256": "
|
|
5580
|
+
"sha256": "d92124875d601a0ff7ad06bf140cdbce2f1160eb53ea950ccbb509b604733d1c"
|
|
5581
5581
|
},
|
|
5582
5582
|
{
|
|
5583
5583
|
"path": "modules/lifecycle/lifecycle.js",
|
|
@@ -5727,7 +5727,7 @@
|
|
|
5727
5727
|
{
|
|
5728
5728
|
"path": "modules/lifecycle/routes/task-write-routes.js",
|
|
5729
5729
|
"mode": "0000644",
|
|
5730
|
-
"sha256": "
|
|
5730
|
+
"sha256": "87843c62d8ce7af3d7d6aa5772791bd52823afe5f2c4b9b7427ca9f00dd14c11"
|
|
5731
5731
|
},
|
|
5732
5732
|
{
|
|
5733
5733
|
"path": "modules/lifecycle/routes/tasks.js",
|
|
@@ -5872,7 +5872,7 @@
|
|
|
5872
5872
|
{
|
|
5873
5873
|
"path": "modules/onboarding/newcomer-restock.js",
|
|
5874
5874
|
"mode": "0000644",
|
|
5875
|
-
"sha256": "
|
|
5875
|
+
"sha256": "65f8f2de9ac4582a6672ddeddc78b23e98d9b32865f7748efe58057145712161"
|
|
5876
5876
|
},
|
|
5877
5877
|
{
|
|
5878
5878
|
"path": "modules/onboarding/onboarding-state.js",
|
|
@@ -7537,12 +7537,12 @@
|
|
|
7537
7537
|
{
|
|
7538
7538
|
"path": "package-lock.json",
|
|
7539
7539
|
"mode": "0000644",
|
|
7540
|
-
"sha256": "
|
|
7540
|
+
"sha256": "6460f782c667dc03846bfbc1dbf299a80e2825488f4eafa664b39767a5c78c47"
|
|
7541
7541
|
},
|
|
7542
7542
|
{
|
|
7543
7543
|
"path": "package.json",
|
|
7544
7544
|
"mode": "0000644",
|
|
7545
|
-
"sha256": "
|
|
7545
|
+
"sha256": "e0c2ff1988ca94bcc1f36b6defa3e098b12db1fdcbcc1b7616441e492628bffe"
|
|
7546
7546
|
},
|
|
7547
7547
|
{
|
|
7548
7548
|
"path": "public-docs/index.html",
|
|
@@ -9242,7 +9242,7 @@
|
|
|
9242
9242
|
{
|
|
9243
9243
|
"path": "src/module-api.js",
|
|
9244
9244
|
"mode": "0000644",
|
|
9245
|
-
"sha256": "
|
|
9245
|
+
"sha256": "2679c7764d5c874f7f1ecc546e552276b9e540bb66ee9f40fb3a281b9294543f"
|
|
9246
9246
|
},
|
|
9247
9247
|
{
|
|
9248
9248
|
"path": "src/module-loader/catalog.js",
|
|
@@ -11837,7 +11837,7 @@
|
|
|
11837
11837
|
{
|
|
11838
11838
|
"path": "tests/reward_gate.mjs",
|
|
11839
11839
|
"mode": "0000644",
|
|
11840
|
-
"sha256": "
|
|
11840
|
+
"sha256": "1ed242c879472dd872c1327c8e7c91de61886dcb108ece3fa455ae6be4b4e025"
|
|
11841
11841
|
},
|
|
11842
11842
|
{
|
|
11843
11843
|
"path": "tests/reward_mode_cost_plus_only.mjs",
|
|
@@ -12364,6 +12364,11 @@
|
|
|
12364
12364
|
"mode": "0000644",
|
|
12365
12365
|
"sha256": "93a5d1e91bfea7af8487b409e60cd609ea25a690df9099d278ebd35f253abcb8"
|
|
12366
12366
|
},
|
|
12367
|
+
{
|
|
12368
|
+
"path": "tests/task_goal_required.mjs",
|
|
12369
|
+
"mode": "0000644",
|
|
12370
|
+
"sha256": "915711866bc194d977225326511c55ac5084b52d10a45a855bc9d6da815f4b8b"
|
|
12371
|
+
},
|
|
12367
12372
|
{
|
|
12368
12373
|
"path": "tests/task_lib.mjs",
|
|
12369
12374
|
"mode": "0000644",
|
|
@@ -1587,5 +1587,7 @@ is load-bearing: the script throws rather than guess if it is missing, and
|
|
|
1587
1587
|
landed since 1.19.567 with no explicit bump. run 34058174866. (task 1002620)
|
|
1588
1588
|
1.19.569 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1589
1589
|
landed since 1.19.568 with no explicit bump. run 34058387853. (task 1002620)
|
|
1590
|
+
1.19.570 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1591
|
+
landed since 1.19.569 with no explicit bump. run 34060442118. (task 1002620)
|
|
1590
1592
|
---------------------------------------------------------------------------
|
|
1591
1593
|
```
|
|
@@ -415,6 +415,10 @@ module.exports = function buildCopyDeskRouter() {
|
|
|
415
415
|
const body = proposals.proposalTaskBody(patch);
|
|
416
416
|
const task = await lifecycle.createTask({
|
|
417
417
|
versionId: building.id,
|
|
418
|
+
// BV1.R11 (task 1003598): no goal to name until the per-version maintenance
|
|
419
|
+
// goal exists (task 1003605, R18). Declared out loud rather than relying on
|
|
420
|
+
// a silent default — see createTask.
|
|
421
|
+
allowCatchAll: true,
|
|
418
422
|
title: proposals.proposalTaskTitle(patch),
|
|
419
423
|
description: flagRef
|
|
420
424
|
? `${body}\n\nRaised from copy-desk flag ${flagRef}.`
|
|
@@ -242,7 +242,18 @@ function cascadeTaskSpec(rule, task) {
|
|
|
242
242
|
+ `the rule rather than only this task.`;
|
|
243
243
|
return {
|
|
244
244
|
versionId: task.version_id,
|
|
245
|
+
// A cascade INHERITS its parent's goal — the follow-up belongs wherever the
|
|
246
|
+
// work that triggered it belongs.
|
|
245
247
|
goalId: task.goal_id != null ? Number(task.goal_id) : null,
|
|
248
|
+
// BV1.R11 (task 1003598): …and when the parent has none, says so. A shipped
|
|
249
|
+
// task can still be goal-less — the 53 legacy catch-all rows, or any task
|
|
250
|
+
// whose version had no general goal to fall into — and under the new guard a
|
|
251
|
+
// null goalId THROWS. That throw is caught by the per-rule try/catch below,
|
|
252
|
+
// so the ship is never at risk, but the cascade task would vanish with only a
|
|
253
|
+
// console line: a silent goal-less gap, which is the exact thing this rule
|
|
254
|
+
// exists to close. Declared only when inheritance actually failed, so a
|
|
255
|
+
// cascade off a normal task still names a real goal.
|
|
256
|
+
allowCatchAll: task.goal_id == null,
|
|
246
257
|
title: String(rule.title(task) || '').trim(),
|
|
247
258
|
description: `${preamble}\n\n${body}`,
|
|
248
259
|
discipline: rule.discipline,
|
|
@@ -116,6 +116,13 @@ async function createTask({
|
|
|
116
116
|
// path with no builder in the request (a seed, a migration backfill) passes
|
|
117
117
|
// nothing and the row honestly reads unattributed, never guessed.
|
|
118
118
|
createdBy = null,
|
|
119
|
+
// BV1.R11 (task 1003598): the ESCAPE HATCH, and it is meant to be loud. A
|
|
120
|
+
// create with no goalId throws unless this is true. It exists for the two
|
|
121
|
+
// in-repo vectors that have no goal to name until the per-version
|
|
122
|
+
// maintenance goal lands (task 1003605, R18) — newcomer-restock and
|
|
123
|
+
// copy-desk. Grep for it before adding a third: the answer is almost always
|
|
124
|
+
// to name a goal instead.
|
|
125
|
+
allowCatchAll = false,
|
|
119
126
|
}, deps = {}) { // deps.client enlists this INSERT in the CALLER'S transaction (task 1003065, BV1.R01); the argument for it is at modules/ideas/inbox.js routeIdeaToGoal
|
|
120
127
|
// task 1498 / ADR 0085: every task carries a deliberate rank floor. Derive it
|
|
121
128
|
// from touches + sensitivity, then let an explicit requiresRank override only
|
|
@@ -140,15 +147,38 @@ async function createTask({
|
|
|
140
147
|
if (rewardMissing(creditsRewardEffective)) {
|
|
141
148
|
creditsRewardEffective = suggestCreditsReward({ est_minutes: estMinutes, kind });
|
|
142
149
|
}
|
|
143
|
-
// task
|
|
144
|
-
//
|
|
145
|
-
//
|
|
146
|
-
//
|
|
147
|
-
//
|
|
148
|
-
//
|
|
149
|
-
//
|
|
150
|
+
// BV1.R11 (task 1003598, goal 1000086, ADR 0250 D4): a goal-less create is no
|
|
151
|
+
// longer SILENT. Task 1763 defaulted any create without a goalId into its
|
|
152
|
+
// version's catch-all "<version> — general" goal, here at the lowest layer, so
|
|
153
|
+
// no caller had to think about it — and that is exactly how 53 open tasks
|
|
154
|
+
// accumulated in two catch-alls that nobody reads. Unscoped work did not sneak
|
|
155
|
+
// past the rule; the rule quietly filed it out of sight.
|
|
156
|
+
//
|
|
157
|
+
// Now a caller either NAMES a goal or says out loud that it cannot, by passing
|
|
158
|
+
// `allowCatchAll: true`. Same landing place, same never-break-a-creator
|
|
159
|
+
// promise — but the exceptions are a one-command grep instead of an absence,
|
|
160
|
+
// and a new create vector added next year gets a thrown error at its first
|
|
161
|
+
// test run rather than a silent home in a bucket.
|
|
162
|
+
//
|
|
163
|
+
// WHY NOT DELETE THE FALLBACK OUTRIGHT, which is what R11 literally asks: two
|
|
164
|
+
// in-repo callers have no goal to name yet. `newcomer-restock` stamps the
|
|
165
|
+
// beginner queue from templates and `copy-desk` files copy proposals; the
|
|
166
|
+
// honest home for both is the per-version MAINTENANCE goal, which is task
|
|
167
|
+
// 1003605 (R18) and still behind R06 and R12. Deleting the fallback today
|
|
168
|
+
// would stop both — the newcomer queue would drain — so the silent half is
|
|
169
|
+
// removed now and the true deletion belongs to R18, which is when the last two
|
|
170
|
+
// opt-ins can name a real goal. Owner's call, recorded here so the remaining
|
|
171
|
+
// gap is not mistaken for an oversight.
|
|
150
172
|
let goalIdEffective = goalId;
|
|
151
173
|
if (goalIdEffective == null) {
|
|
174
|
+
if (allowCatchAll !== true) {
|
|
175
|
+
throw new Error(
|
|
176
|
+
'createTask: goalId is required — every task belongs to a goal (BV1.R11). ' +
|
|
177
|
+
'A vector that genuinely cannot name one yet must pass allowCatchAll: true, ' +
|
|
178
|
+
'which files into the version catch-all and is greppable until R18 gives it ' +
|
|
179
|
+
'a maintenance goal.'
|
|
180
|
+
);
|
|
181
|
+
}
|
|
152
182
|
goalIdEffective = await generalGoalIdForVersion(versionId);
|
|
153
183
|
}
|
|
154
184
|
const { rows } = await (deps.client || pool).query(
|
|
@@ -60,6 +60,11 @@ async function seedKickoffIfEmpty({ builderId = null } = {}, deps = {}) {
|
|
|
60
60
|
discipline: t.discipline || 'engineer',
|
|
61
61
|
source: 'first-sign-in',
|
|
62
62
|
createdBy: builderId,
|
|
63
|
+
// BV1.R11 (task 1003598): this runs on an instance with zero tasks and, in
|
|
64
|
+
// the scaffold's own seed order, zero goals — there is literally nothing to
|
|
65
|
+
// name yet. Declared rather than defaulted; the founder's first board is the
|
|
66
|
+
// one place a catch-all is the honest answer.
|
|
67
|
+
allowCatchAll: true,
|
|
63
68
|
});
|
|
64
69
|
seeded += 1;
|
|
65
70
|
}
|
|
@@ -165,6 +165,18 @@ module.exports = function registerTaskWriteRoutes(router) {
|
|
|
165
165
|
// Validate an explicit goal_id: it must exist AND belong to THIS version (a task
|
|
166
166
|
// can't be parented under another version's goal). Absent → the default-goal
|
|
167
167
|
// trigger files it under the version's catch-all goal, exactly as before.
|
|
168
|
+
// BV1.R11 (task 1003598, goal 1000086, ADR 0250 D4): goal_id is REQUIRED on
|
|
169
|
+
// this vector. It used to be optional and a goal-less task was filed into the
|
|
170
|
+
// version catch-all, which is how 53 open tasks ended up in two buckets
|
|
171
|
+
// nobody reads. This is the human/agent door, and it always has a goal to
|
|
172
|
+
// name — /builder-start lists work by goal, and POST /goals/:id/tasks has
|
|
173
|
+
// required one since BV1.R60.
|
|
174
|
+
if (body.goal_id == null) {
|
|
175
|
+
return res.fail('goal_id_required', {
|
|
176
|
+
status: 400,
|
|
177
|
+
message: 'goal_id is required — every task belongs to a goal. List them at GET /goals?version=<id>, or author under one directly with POST /goals/:id/tasks.',
|
|
178
|
+
});
|
|
179
|
+
}
|
|
168
180
|
if (body.goal_id != null) {
|
|
169
181
|
const goal = await db.getGoal(body.goal_id);
|
|
170
182
|
if (!goal || String(goal.version_id) !== String(body.version_id)) {
|
|
@@ -156,6 +156,10 @@ async function restockTemplate(discipline, template, floors) {
|
|
|
156
156
|
const seq = maxSeq + 1 + i;
|
|
157
157
|
const task = await db.createTask({
|
|
158
158
|
versionId: template.version_id,
|
|
159
|
+
// BV1.R11 (task 1003598): no goal to name until the per-version maintenance
|
|
160
|
+
// goal exists (task 1003605, R18). Declared out loud rather than relying on
|
|
161
|
+
// a silent default — see createTask.
|
|
162
|
+
allowCatchAll: true,
|
|
159
163
|
title: template.title,
|
|
160
164
|
description: buildDescription(template),
|
|
161
165
|
status: 'ready',
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.570",
|
|
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.570",
|
|
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.570",
|
|
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",
|
package/src/module-api.js
CHANGED
|
@@ -49,7 +49,7 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
|
|
|
49
49
|
// there. scripts/gds/bump-version.js still rewrites the literal below; it appends
|
|
50
50
|
// the entry to that file. Look for a version's history there, not here.
|
|
51
51
|
// ---------------------------------------------------------------------------
|
|
52
|
-
const CORE_VERSION = '1.19.
|
|
52
|
+
const CORE_VERSION = '1.19.570'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
53
53
|
|
|
54
54
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
55
55
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
package/tests/reward_gate.mjs
CHANGED
|
@@ -210,7 +210,7 @@ async function withStubbedCreatePool(run) {
|
|
|
210
210
|
|
|
211
211
|
t('a task CREATED with NO reward is born with a positive, capped value', async () => {
|
|
212
212
|
await withStubbedCreatePool(async (captured) => {
|
|
213
|
-
const row = await db.createTask({ versionId: 1, title: 't', kind: 'feature', estMinutes: 40 });
|
|
213
|
+
const row = await db.createTask({ versionId: 1, goalId: 77, title: 't', kind: 'feature', estMinutes: 40 });
|
|
214
214
|
assert.ok(captured.creditsReward > 0, 'INSERT must bind a positive credits_reward');
|
|
215
215
|
assert.equal(captured.creditsReward, 40, 'feature @40min -> 40 drachmae');
|
|
216
216
|
assert.ok(captured.creditsReward <= db.REWARD_SUGGESTION_CAP, 'must respect the cap');
|
|
@@ -220,7 +220,7 @@ t('a task CREATED with NO reward is born with a positive, capped value', async (
|
|
|
220
220
|
|
|
221
221
|
t('a task CREATED with credits_reward 0 (the route default) is auto-assigned', async () => {
|
|
222
222
|
await withStubbedCreatePool(async (captured) => {
|
|
223
|
-
await db.createTask({ versionId: 1, title: 't', creditsReward: 0, kind: 'bug', estMinutes: 40 });
|
|
223
|
+
await db.createTask({ versionId: 1, goalId: 77, title: 't', creditsReward: 0, kind: 'bug', estMinutes: 40 });
|
|
224
224
|
// bug weight 1.25 -> 50; 0 is treated as "unset" so the default kicks in.
|
|
225
225
|
assert.equal(captured.creditsReward, 50);
|
|
226
226
|
});
|
|
@@ -228,14 +228,14 @@ t('a task CREATED with credits_reward 0 (the route default) is auto-assigned', a
|
|
|
228
228
|
|
|
229
229
|
t('a huge-est new task is CAPPED at creation, not ballooned', async () => {
|
|
230
230
|
await withStubbedCreatePool(async (captured) => {
|
|
231
|
-
await db.createTask({ versionId: 1, title: 't', kind: 'feature', estMinutes: 9999 });
|
|
231
|
+
await db.createTask({ versionId: 1, goalId: 77, title: 't', kind: 'feature', estMinutes: 9999 });
|
|
232
232
|
assert.equal(captured.creditsReward, db.REWARD_SUGGESTION_CAP);
|
|
233
233
|
});
|
|
234
234
|
});
|
|
235
235
|
|
|
236
236
|
t('an EXPLICIT positive credits_reward is PRESERVED (never overridden)', async () => {
|
|
237
237
|
await withStubbedCreatePool(async (captured) => {
|
|
238
|
-
await db.createTask({ versionId: 1, title: 't', creditsReward: 17, kind: 'feature', estMinutes: 40 });
|
|
238
|
+
await db.createTask({ versionId: 1, goalId: 77, title: 't', creditsReward: 17, kind: 'feature', estMinutes: 40 });
|
|
239
239
|
assert.equal(captured.creditsReward, 17, 'author-set positive value must survive untouched');
|
|
240
240
|
});
|
|
241
241
|
});
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
// tests/task_goal_required.mjs — a goal-less create is no longer SILENT
|
|
2
|
+
// (BV1.R11, task 1003598, goal 1000086, ADR 0250 D4).
|
|
3
|
+
//
|
|
4
|
+
// WHAT WAS ACTUALLY WRONG. Task 1763 made `createTask` default any create with no
|
|
5
|
+
// `goalId` into its version's catch-all "<version> — general" goal, at the lowest
|
|
6
|
+
// layer, so that no caller had to think about it. The invariant "every task
|
|
7
|
+
// belongs to a goal" then held — technically — while 53 open tasks accumulated in
|
|
8
|
+
// two buckets nobody reads. Unscoped work never snuck past the rule; the rule
|
|
9
|
+
// filed it out of sight.
|
|
10
|
+
//
|
|
11
|
+
// So the defect is the SILENCE, not the bucket. A caller now either names a goal
|
|
12
|
+
// or says out loud that it cannot, by passing `allowCatchAll: true`: same landing
|
|
13
|
+
// place, same never-break-a-creator promise, but the exceptions are a one-command
|
|
14
|
+
// grep instead of an absence, and a create vector added next year gets a thrown
|
|
15
|
+
// error at its first test run rather than a quiet home in a bucket.
|
|
16
|
+
//
|
|
17
|
+
// NOT FULLY DELETED, and that is a recorded decision rather than an oversight.
|
|
18
|
+
// R11 literally asks for the fallback's deletion, but two in-repo vectors have no
|
|
19
|
+
// goal to name yet — `newcomer-restock` (the beginner queue) and `copy-desk`
|
|
20
|
+
// (copy proposals) — whose honest home is the per-version maintenance goal, task
|
|
21
|
+
// 1003605 (R18), still behind R06 and R12. Deleting today would drain the
|
|
22
|
+
// newcomer queue. The owner chose to remove the silent half now and leave the
|
|
23
|
+
// true deletion to R18. This file pins the opt-in roster so it cannot grow
|
|
24
|
+
// quietly in the meantime.
|
|
25
|
+
//
|
|
26
|
+
// Run: node --test tests/task_goal_required.mjs
|
|
27
|
+
|
|
28
|
+
import assert from 'node:assert/strict';
|
|
29
|
+
import { test } from 'node:test';
|
|
30
|
+
import { readFileSync } from 'node:fs';
|
|
31
|
+
import { createRequire } from 'node:module';
|
|
32
|
+
|
|
33
|
+
process.env.NODE_ENV = 'test';
|
|
34
|
+
const require = createRequire(import.meta.url);
|
|
35
|
+
const { createTask } = require('../modules/lifecycle/db.js');
|
|
36
|
+
|
|
37
|
+
const src = (rel) => readFileSync(new URL('../' + rel, import.meta.url), 'utf8');
|
|
38
|
+
|
|
39
|
+
// A pool that would happily create the row, so a rejection can only come from the
|
|
40
|
+
// guard and never from a database that was never reached.
|
|
41
|
+
const permissivePool = {
|
|
42
|
+
query: async () => ({ rows: [{ id: '1', goal_id: null, title: 'x' }] }),
|
|
43
|
+
connect: async () => ({ query: async () => ({ rows: [] }), release() {} }),
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
// ---- the guard --------------------------------------------------------------
|
|
47
|
+
|
|
48
|
+
test('a create with no goalId THROWS instead of quietly filing into a bucket', async () => {
|
|
49
|
+
await assert.rejects(
|
|
50
|
+
() => createTask({ versionId: 'BONGOS-V1', title: 't' }, { pool: permissivePool }),
|
|
51
|
+
/goalId is required/,
|
|
52
|
+
'the old behaviour was to succeed and say nothing');
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test('the error tells the caller both ways out', async () => {
|
|
56
|
+
const err = await createTask({ versionId: 'BONGOS-V1', title: 't' }, { pool: permissivePool })
|
|
57
|
+
.then(() => null, (e) => e);
|
|
58
|
+
assert.ok(err, 'must reject');
|
|
59
|
+
assert.match(err.message, /BV1\.R11/, 'names the rule, so the reason is findable');
|
|
60
|
+
assert.match(err.message, /allowCatchAll: true/, 'and the escape hatch, spelled exactly');
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('only a literal true opts in — a truthy value still throws', async () => {
|
|
64
|
+
for (const v of [1, 'yes', {}, 'true']) {
|
|
65
|
+
await assert.rejects(
|
|
66
|
+
() => createTask({ versionId: 'BONGOS-V1', title: 't', allowCatchAll: v }, { pool: permissivePool }),
|
|
67
|
+
/goalId is required/,
|
|
68
|
+
`${JSON.stringify(v)} must not open the hatch`);
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test('naming a goal needs no flag at all', async () => {
|
|
73
|
+
// The ordinary path must stay ordinary: a caller with a goal never learns this
|
|
74
|
+
// parameter exists.
|
|
75
|
+
await createTask(
|
|
76
|
+
{ versionId: 'BONGOS-V1', title: 't', goalId: 77 },
|
|
77
|
+
{ pool: permissivePool, client: permissivePool },
|
|
78
|
+
);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
// ---- the route --------------------------------------------------------------
|
|
82
|
+
|
|
83
|
+
const WRITE = src('modules/lifecycle/routes/task-write-routes.js');
|
|
84
|
+
const POST_TASKS = (() => {
|
|
85
|
+
const a = WRITE.indexOf("router.post('/tasks'");
|
|
86
|
+
assert.notEqual(a, -1);
|
|
87
|
+
return WRITE.slice(a, WRITE.indexOf("router.patch('/tasks/:id'"));
|
|
88
|
+
})();
|
|
89
|
+
|
|
90
|
+
test('POST /tasks refuses a body with no goal_id, as a 400 that says where to look', () => {
|
|
91
|
+
assert.match(POST_TASKS, /if \(body\.goal_id == null\) \{/);
|
|
92
|
+
assert.match(POST_TASKS, /res\.fail\('goal_id_required', \{[\s\S]*?status: 400/,
|
|
93
|
+
'a missing required field is the caller\'s mistake, not a conflict');
|
|
94
|
+
assert.match(POST_TASKS, /GET \/goals\?version=/,
|
|
95
|
+
'a refusal that does not say how to comply gets worked around');
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test('the route refuses BEFORE it validates the goal it was not given', () => {
|
|
99
|
+
const required = POST_TASKS.indexOf("'goal_id_required'");
|
|
100
|
+
const validate = POST_TASKS.indexOf("'bad_goal_id'");
|
|
101
|
+
const create = POST_TASKS.indexOf('await db.createTask(');
|
|
102
|
+
assert.ok(required !== -1 && validate !== -1 && create !== -1);
|
|
103
|
+
assert.ok(required < validate, 'absent and wrong are different answers');
|
|
104
|
+
assert.ok(required < create);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test('POST /tasks does NOT pass the escape hatch — this door always has a goal', () => {
|
|
108
|
+
assert.equal(/allowCatchAll/.test(POST_TASKS), false,
|
|
109
|
+
'/builder-start lists work by goal; the human/agent vector can always name one');
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
// ---- the opt-in roster, pinned ----------------------------------------------
|
|
113
|
+
|
|
114
|
+
const OPT_INS = [
|
|
115
|
+
['modules/onboarding/newcomer-restock.js', 'the beginner queue, stamped from templates'],
|
|
116
|
+
['modules/copy-desk/routes/copy-desk.js', 'copy-change proposals'],
|
|
117
|
+
['modules/lifecycle/kickoff-seed.js', "the founder's first board, on an instance with no goals at all"],
|
|
118
|
+
['modules/lifecycle/cascade.js', 'a follow-up off a parent task that is itself goal-less'],
|
|
119
|
+
];
|
|
120
|
+
|
|
121
|
+
test('exactly four vectors opt in, and each says why', () => {
|
|
122
|
+
for (const [file, why] of OPT_INS) {
|
|
123
|
+
const body = src(file);
|
|
124
|
+
assert.match(body, /allowCatchAll/, `${file} should opt in (${why})`);
|
|
125
|
+
assert.match(body, /BV1\.R11|1003605|R18/,
|
|
126
|
+
`${file} must explain itself — an undocumented opt-in is the silence again`);
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test('cascade opts in ONLY when inheritance actually failed', () => {
|
|
131
|
+
// The others opt in unconditionally because they have no goal to name at all.
|
|
132
|
+
// A cascade normally inherits its parent's goal and must keep doing so — the
|
|
133
|
+
// hatch is for a goal-less PARENT (a legacy catch-all row), not for cascades in
|
|
134
|
+
// general. A blanket `true` here would quietly re-open the hole for every
|
|
135
|
+
// follow-up task the system files.
|
|
136
|
+
const body = src('modules/lifecycle/cascade.js');
|
|
137
|
+
assert.match(body, /allowCatchAll: task\.goal_id == null,/);
|
|
138
|
+
assert.equal(/allowCatchAll: true/.test(body), false,
|
|
139
|
+
'an unconditional hatch here would cover every cascade, not just the legacy ones');
|
|
140
|
+
assert.match(body, /goalId: task\.goal_id != null \? Number\(task\.goal_id\) : null,/,
|
|
141
|
+
'inheritance stays the primary path');
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test('a goal-less parent does not silently lose its cascade task', async () => {
|
|
145
|
+
// The regression this pair of fixes closes: under the guard, a null goalId
|
|
146
|
+
// throws; the throw is caught per-rule so the ship is safe, and the cascade
|
|
147
|
+
// task would have vanished with only a console line — a silent goal-less gap,
|
|
148
|
+
// which is the exact thing this rule exists to close.
|
|
149
|
+
const { runCascadesForShip } = require('../modules/lifecycle/cascade.js');
|
|
150
|
+
const specs = [];
|
|
151
|
+
const report = await runCascadesForShip({ id: 5 }, {
|
|
152
|
+
getTask: async () => ({
|
|
153
|
+
id: 5, version_id: 'BONGOS-V1', goal_id: null, status: 'shipped',
|
|
154
|
+
discipline: 'artist', kind: 'feature', title: 'legacy', touches: [],
|
|
155
|
+
}),
|
|
156
|
+
findTaskBySourceRef: async () => null,
|
|
157
|
+
createTask: async (spec) => { specs.push(spec); return { id: 9 }; },
|
|
158
|
+
});
|
|
159
|
+
assert.deepEqual(report.errors, [], 'no rule may error on a goal-less parent');
|
|
160
|
+
for (const spec of specs) {
|
|
161
|
+
assert.equal(spec.goalId, null, 'nothing to inherit');
|
|
162
|
+
assert.equal(spec.allowCatchAll, true, 'so it declares the catch-all rather than throwing');
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test('a cascade off a NORMAL parent inherits and does not open the hatch', async () => {
|
|
167
|
+
const { runCascadesForShip } = require('../modules/lifecycle/cascade.js');
|
|
168
|
+
const specs = [];
|
|
169
|
+
await runCascadesForShip({ id: 5 }, {
|
|
170
|
+
getTask: async () => ({
|
|
171
|
+
id: 5, version_id: 'BONGOS-V1', goal_id: 77, status: 'shipped',
|
|
172
|
+
discipline: 'artist', kind: 'feature', title: 'normal', touches: [],
|
|
173
|
+
}),
|
|
174
|
+
findTaskBySourceRef: async () => null,
|
|
175
|
+
createTask: async (spec) => { specs.push(spec); return { id: 9 }; },
|
|
176
|
+
});
|
|
177
|
+
for (const spec of specs) {
|
|
178
|
+
assert.equal(spec.goalId, 77, 'the follow-up belongs where the work belongs');
|
|
179
|
+
assert.equal(spec.allowCatchAll, false, 'and the hatch stays shut');
|
|
180
|
+
}
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test('no FIFTH vector has appeared', () => {
|
|
184
|
+
// The roster is the point: if this fails, either a new create vector took the
|
|
185
|
+
// easy way out, or R18 landed and one of these can now name a real goal — in
|
|
186
|
+
// which case delete its opt-in and this line, do not extend the list.
|
|
187
|
+
const roster = new Set([...OPT_INS.map(([f]) => f), 'modules/lifecycle/db-tasks.js']);
|
|
188
|
+
const suspects = [
|
|
189
|
+
'modules/ideas/inbox.js', 'modules/lifecycle/routes/goals.js',
|
|
190
|
+
'modules/lifecycle/routes/task-write-routes.js',
|
|
191
|
+
];
|
|
192
|
+
for (const f of suspects) {
|
|
193
|
+
if (roster.has(f)) continue;
|
|
194
|
+
assert.equal(/allowCatchAll/.test(src(f)), false,
|
|
195
|
+
`${f} must name a goal rather than opting out`);
|
|
196
|
+
}
|
|
197
|
+
});
|
|
198
|
+
test('the vectors that CAN name a goal still do', () => {
|
|
199
|
+
assert.match(src('modules/ideas/inbox.js'), /goalId: goal\.id/,
|
|
200
|
+
'idea routing names the goal it routed to');
|
|
201
|
+
assert.match(src('modules/lifecycle/routes/goals.js'), /db\.createTask\(\{/,
|
|
202
|
+
'and the goal-authored vector has stamped its goal since BV1.R60');
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
// ---- the fallback itself ----------------------------------------------------
|
|
206
|
+
|
|
207
|
+
test('the catch-all resolver survives, reachable only through the declared hatch', () => {
|
|
208
|
+
const dbTasks = src('modules/lifecycle/db-tasks.js');
|
|
209
|
+
assert.match(dbTasks, /generalGoalIdForVersion\(versionId\)/,
|
|
210
|
+
'R18 is when this goes; deleting it today would drain the newcomer queue');
|
|
211
|
+
const guard = dbTasks.slice(dbTasks.indexOf('let goalIdEffective = goalId;'));
|
|
212
|
+
const hatch = guard.indexOf('allowCatchAll !== true');
|
|
213
|
+
const resolve = guard.indexOf('generalGoalIdForVersion(versionId)');
|
|
214
|
+
assert.ok(hatch !== -1 && hatch < resolve,
|
|
215
|
+
'the throw comes first — the resolver is unreachable without the flag');
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
test('the remaining gap is written down where the next reader will stand', () => {
|
|
219
|
+
const dbTasks = src('modules/lifecycle/db-tasks.js');
|
|
220
|
+
assert.match(dbTasks, /WHY NOT DELETE THE FALLBACK OUTRIGHT/,
|
|
221
|
+
'so the partial job reads as a decision, not an unfinished edit');
|
|
222
|
+
assert.match(dbTasks, /1003605/, 'and names the task that finishes it');
|
|
223
|
+
});
|