@bongos/core 1.19.450 → 1.19.452

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 CHANGED
@@ -2,11 +2,11 @@
2
2
  "artifact": "bongos-core",
3
3
  "manifest_schema": 1,
4
4
  "generator": "scripts/gds/package-core.js",
5
- "core_version": "1.19.450",
6
- "core_contract": "1.19.450",
7
- "source_commit": "51f7db91d5aa247c668f932c8c792afed0c0b690",
5
+ "core_version": "1.19.452",
6
+ "core_contract": "1.19.452",
7
+ "source_commit": "a59941e00bdc972cede45d82263d6bb9a334d59d",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-08-31T16:36:29.965Z",
9
+ "built_at": "2026-09-01T19:56:39.788Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
12
  "docs_redacted": 371,
@@ -17,7 +17,7 @@
17
17
  "gate": "passed"
18
18
  },
19
19
  "file_count": 2183,
20
- "tree_sha256": "c23220ed7525b84e74efcdd12d8fa798ef9c2ae4b39b5d1a7b97c9eb35634bee",
20
+ "tree_sha256": "d6e755d26822b3fbb320367835d28cb2e6e317309a6da4985730541e202fec63",
21
21
  "files": [
22
22
  {
23
23
  "path": ".claude/skills/blocker-review/SKILL.md",
@@ -2092,7 +2092,7 @@
2092
2092
  {
2093
2093
  "path": "docs/module-api-changelog.md",
2094
2094
  "mode": "0000644",
2095
- "sha256": "dc1ef1a70ed26e1bfa26b2752b6838c1f7f8871385a6bb1c2a7e55d2164f33fb"
2095
+ "sha256": "1b44d9a28067aee44f6461a6ec2ff31dcbcd1d92aba9b5e5d53ac97dcb782cbe"
2096
2096
  },
2097
2097
  {
2098
2098
  "path": "docs/modules-contract.md",
@@ -4647,7 +4647,7 @@
4647
4647
  {
4648
4648
  "path": "modules/lifecycle/db-tasks.js",
4649
4649
  "mode": "0000644",
4650
- "sha256": "745663b0a264ae742702919fc9486f49723010dd797e1fe7151de5d65b98bf21"
4650
+ "sha256": "cf646edc2f71e39aee26cb5648cc25915b5688c7d6356345c0365ada37fdb400"
4651
4651
  },
4652
4652
  {
4653
4653
  "path": "modules/lifecycle/db-versions.js",
@@ -6302,12 +6302,12 @@
6302
6302
  {
6303
6303
  "path": "package-lock.json",
6304
6304
  "mode": "0000644",
6305
- "sha256": "a31da0642871f1ab2b99bdc8f0d133fb9810e08ba52410ba3029d7437d234d69"
6305
+ "sha256": "a24646cf791a4ef41bf5fbca236cf24a27c9d05ba0f4419483054f38b849688e"
6306
6306
  },
6307
6307
  {
6308
6308
  "path": "package.json",
6309
6309
  "mode": "0000644",
6310
- "sha256": "43a909e803e69e33cda54842705ee13e870f836c5c7966117658577109333a6e"
6310
+ "sha256": "76ba0c0adc1241f0051796e43d8519486163d142b23a57704d60aabd56806cb6"
6311
6311
  },
6312
6312
  {
6313
6313
  "path": "public-docs/index.html",
@@ -6882,7 +6882,7 @@
6882
6882
  {
6883
6883
  "path": "scripts/gds/land-watch.js",
6884
6884
  "mode": "0000644",
6885
- "sha256": "4b53094a4b69d318196d6913aa40b35bce4058272b124064f2c79adbc1330714"
6885
+ "sha256": "72072f2233fdb4fe228597276421eb616077c723f4cdf1af558a29d23ecfadf4"
6886
6886
  },
6887
6887
  {
6888
6888
  "path": "scripts/gds/learning-capture.js",
@@ -7957,7 +7957,7 @@
7957
7957
  {
7958
7958
  "path": "src/module-api.js",
7959
7959
  "mode": "0000644",
7960
- "sha256": "a5564186c70f7c4f6f6abe293168cd66e2a563dcf094f8655f9a6db12fe90f53"
7960
+ "sha256": "4b7bee96fc2dfb083dcc43f811b46f135c4103cf719e68546ee2b898569cfc65"
7961
7961
  },
7962
7962
  {
7963
7963
  "path": "src/module-loader/catalog.js",
@@ -8977,7 +8977,7 @@
8977
8977
  {
8978
8978
  "path": "tests/goal_advisory.mjs",
8979
8979
  "mode": "0000644",
8980
- "sha256": "7488d41a0d962f681f9482e4c06f518f38be824e8fa936b5bd7b1c1b70838c3d"
8980
+ "sha256": "1b1c403574132def4c4beb98595987e90b720a851f29e42f643f1f00f285da5c"
8981
8981
  },
8982
8982
  {
8983
8983
  "path": "tests/goal_category.mjs",
@@ -9627,7 +9627,7 @@
9627
9627
  {
9628
9628
  "path": "tests/land_watch.mjs",
9629
9629
  "mode": "0000644",
9630
- "sha256": "902c84c460d4c3bda61ec80be7da935085a5f66a8a1c508efe747c4366a717e6"
9630
+ "sha256": "b1851f3c0965f1bcfea060675d7e28ec7a0787e99dda7f1c3782ed2c0a6b4454"
9631
9631
  },
9632
9632
  {
9633
9633
  "path": "tests/landing_legal_pages.mjs",
@@ -10747,7 +10747,7 @@
10747
10747
  {
10748
10748
  "path": "tests/task_goal_link.mjs",
10749
10749
  "mode": "0000644",
10750
- "sha256": "d4f417d485cde211b7664c017f1909eb31bd86539cfc0128a08bf2b474023b80"
10750
+ "sha256": "514be815c0466e4e2865ad85cb7a2ca206e5b46abc7b345da2c36278667c8bb2"
10751
10751
  },
10752
10752
  {
10753
10753
  "path": "tests/task_lib.mjs",
@@ -1337,5 +1337,9 @@ is load-bearing: the script throws rather than guess if it is missing, and
1337
1337
  landed since 1.19.448 with no explicit bump. run 33406714743. (task 1002620)
1338
1338
  1.19.450 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1339
1339
  landed since 1.19.449 with no explicit bump. run 33414986466. (task 1002620)
1340
+ 1.19.451 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1341
+ landed since 1.19.450 with no explicit bump. run 33549781105. (task 1002620)
1342
+ 1.19.452 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1343
+ landed since 1.19.451 with no explicit bump. run 33552353620. (task 1002620)
1340
1344
  ---------------------------------------------------------------------------
1341
1345
  ```
@@ -23,6 +23,20 @@ const { deriveRequiredRank, highestRank } = require('./db-rank-authz.js');
23
23
  // -------------------------------------------------------------------------
24
24
 
25
25
 
26
+ // The version statuses that can still receive new work. 'shipped' and 'frozen' are
27
+ // closed: a version whose scope is sealed cannot be told about a bug found afterwards.
28
+ // Mirrors the CHECK constraint on versions.status (migrations/003_pms.sql).
29
+ const VERSION_STATUSES_OPEN_TO_WORK = new Set(['planning', 'building']);
30
+
31
+ // versionAcceptsNewWork — PURE, FAIL-CLOSED. Is this version still a place work can
32
+ // land? Exported so the truth table is pinned by a DB-free unit test, and so a caller
33
+ // that needs the same judgement asks this instead of respelling the status list.
34
+ // Anything unrecognised (null, a typo, a status added later) reads false: filing into
35
+ // a version we cannot vouch for is the failure this predicate exists to prevent.
36
+ function versionAcceptsNewWork(status) {
37
+ return typeof status === 'string' && VERSION_STATUSES_OPEN_TO_WORK.has(status);
38
+ }
39
+
26
40
  // task 1763: resolve a version's catch-all "<version> — general" goal (created by
27
41
  // migration 160, created_by NULL). This is the fallback goal a goal-less task
28
42
  // defaults into (see createTask) so the "every task belongs to a goal" invariant
@@ -32,14 +46,39 @@ const { deriveRequiredRank, highestRank } = require('./db-rank-authz.js');
32
46
  // unit-testable DB-free. The title mirrors migration 160's insert exactly (a spaced
33
47
  // em-dash); it is built by ../goal-advisory.generalGoalTitle so this SQL and the
34
48
  // advisory that detects the same goal can never disagree (task 1002539).
49
+ //
50
+ // task 1003061 (BV1.R03): it also refuses a CLOSED version's catch-all. That is the
51
+ // 1000771 failure shape — Discord #bugs hardcoded GDS-V3, a shipped version, so every
52
+ // bug reported through it resolved into "GDS-V3 — general" and was filed somewhere
53
+ // nobody would ever look again. routeIdeaToGoal (BV1.R01) already refuses an EXPLICIT
54
+ // goal on a closed version; this closes the IMPLICIT door that every other create
55
+ // vector walks through. Returning null is deliberately NOT an error: the create still
56
+ // succeeds with goal_id NULL, and the orphan advisory then says so out loud — a task
57
+ // visibly without a home beats one quietly entombed.
58
+ //
59
+ // The status is judged in JS rather than the WHERE clause so the rule is one exported,
60
+ // unit-tested predicate instead of a string list duplicated into SQL. The JOIN is inner
61
+ // on purpose: a goal whose version row is missing is not a home either.
35
62
  async function generalGoalIdForVersion(versionId, deps = {}) {
36
63
  if (versionId == null) return null;
37
64
  const activePool = (deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : pool;
38
65
  const { rows } = await activePool.query(
39
- 'SELECT id FROM goals WHERE version_id = $1 AND title = $2 LIMIT 1',
66
+ `SELECT g.id, v.status AS version_status
67
+ FROM goals g
68
+ JOIN versions v ON v.id = g.version_id
69
+ WHERE g.version_id = $1 AND g.title = $2
70
+ LIMIT 1`,
40
71
  [versionId, generalGoalTitle(versionId)]
41
72
  );
42
- return rows[0] ? rows[0].id : null;
73
+ if (!rows[0]) return null;
74
+ if (!versionAcceptsNewWork(rows[0].version_status)) {
75
+ log.debug(
76
+ { versionId, versionStatus: rows[0].version_status ?? null, goalId: rows[0].id },
77
+ 'catch-all goal refused: version is closed to new work (task 1003061)'
78
+ );
79
+ return null;
80
+ }
81
+ return rows[0].id;
43
82
  }
44
83
 
45
84
  async function createTask({
@@ -870,4 +909,5 @@ module.exports = {
870
909
  updateTaskRequiresRank,
871
910
  updateTaskSecuritySensitive,
872
911
  updateTaskStatus,
912
+ versionAcceptsNewWork,
873
913
  };
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.450",
3
+ "version": "1.19.452",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.450",
9
+ "version": "1.19.452",
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.450",
3
+ "version": "1.19.452",
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",
@@ -78,14 +78,21 @@ function removeBreadcrumb(fileOrId) {
78
78
  }
79
79
 
80
80
  // PURE decision — exported so the truth table is unit-tested with no fs/network.
81
- // 'emit' → the task has landed (shipped): render the shipped card, then clear.
82
- // 'drop' → give up watching: stale, OR it left the land path (grade-failed back to
83
- // completed, released, …) so the confirmed→shipped transition won't happen.
81
+ // 'emit' → the task landed inside the watch window: render the shipped card, then clear.
82
+ // 'drop' → give up watching: past the window, OR it left the land path (grade-failed back
83
+ // to completed, released, …) so the confirmed→shipped transition won't happen.
84
84
  // 'keep' → still landing (confirmed), or a transient read failure — retry next turn.
85
- // emit wins over stale so a just-landed breadcrumb still shows even if old.
85
+ //
86
+ // The window wins over EVERY status, 'shipped' included (task 1003466). It used not to:
87
+ // 'emit' was checked first, so a breadcrumb for a task that landed days ago still rendered a
88
+ // "Shipped" banner. That went unnoticed until a dead publish-reconciler (task 1002419) let a
89
+ // backlog build — eleven tasks flipped to shipped at once and the builder paid a prompt-turn
90
+ // of stale news for each. A land the builder has already moved past is not news; it is spam.
91
+ // An unknown/unparseable age is treated as past the window for the same reason: we cannot
92
+ // prove the crumb is fresh, and the safe direction is always silence.
86
93
  function classifyLand(task, ageMs, { staleMs = DEFAULT_STALE_MS } = {}) {
94
+ if (!Number.isFinite(ageMs) || ageMs > staleMs) return 'drop';
87
95
  if (task && task.status === 'shipped') return 'emit';
88
- if (typeof ageMs === 'number' && ageMs > staleMs) return 'drop';
89
96
  if (!task || typeof task.status !== 'string') return 'keep'; // couldn't read → retry
90
97
  if (task.status === 'confirmed') return 'keep'; // still landing
91
98
  return 'drop'; // no longer on the land path
@@ -126,21 +133,33 @@ function buildShippedEnvelope(crumb, task) {
126
133
 
127
134
  // Walk the breadcrumbs; emit AT MOST ONE shipped card per run (avoid flooding a turn),
128
135
  // dropping the give-ups. Returns the envelope to inject, or null. Deps injected for tests.
136
+ //
137
+ // The backlog drains in THIS pass (task 1003466): the newest land gets the card and every
138
+ // other landed crumb is folded into one "also landed" line and cleared. Holding the extras
139
+ // for later turns is what turned a backlog into a card per prompt — the builder had to ask
140
+ // why the session kept showing cards instead of working.
129
141
  async function watch({ nowMs = Date.now(), staleMs = DEFAULT_STALE_MS, getTask = fetchTask, readCrumbs = readBreadcrumbs, removeCrumb = removeBreadcrumb } = {}) {
130
142
  const crumbs = readCrumbs().sort((a, b) => String(b.at).localeCompare(String(a.at))); // newest first
131
143
  let envelope = null;
144
+ const alsoLanded = [];
132
145
  for (const crumb of crumbs) {
133
- const ageMs = nowMs - Date.parse(crumb.at || '') || 0;
146
+ const at = Date.parse(crumb.at || '');
147
+ const ageMs = Number.isFinite(at) ? nowMs - at : Infinity; // unparseable → past the window
134
148
  const task = await getTask(crumb.taskId);
135
- const verdict = classifyLand(task, Number.isFinite(ageMs) ? ageMs : 0, { staleMs });
149
+ const verdict = classifyLand(task, ageMs, { staleMs });
136
150
  if (verdict === 'emit') {
137
- if (!envelope) { envelope = buildShippedEnvelope(crumb, task); removeCrumb(crumb._file); }
138
- // a 2nd shipped crumb waits for the next turn (one card at a time)
151
+ if (!envelope) envelope = buildShippedEnvelope(crumb, task);
152
+ else alsoLanded.push(crumb.taskId);
153
+ removeCrumb(crumb._file);
139
154
  } else if (verdict === 'drop') {
140
155
  removeCrumb(crumb._file);
141
156
  }
142
157
  // 'keep' → leave it
143
158
  }
159
+ // Written as `task N`, never `#N` — a bare #NNN autolinks to a GitHub issue and 404s.
160
+ if (envelope && alsoLanded.length) {
161
+ envelope.warnings = [`Also landed: ${alsoLanded.map((id) => `task ${id}`).join(', ')}.`];
162
+ }
144
163
  return envelope;
145
164
  }
146
165
 
package/src/module-api.js CHANGED
@@ -33,7 +33,7 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
33
33
  // there. scripts/gds/bump-version.js still rewrites the literal below; it appends
34
34
  // the entry to that file. Look for a version's history there, not here.
35
35
  // ---------------------------------------------------------------------------
36
- const CORE_VERSION = '1.19.450'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
36
+ const CORE_VERSION = '1.19.452'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
37
37
 
38
38
  // A namespaced logger so a module's log lines are attributable + consistent.
39
39
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -108,7 +108,7 @@ await test('candidateGoals is total — non-array, empty, and missing scope_modu
108
108
 
109
109
  await test('db.js resolves the catch-all through generalGoalTitle — no second spelling', () => {
110
110
  assert.match(dbSrc, /require\('\.\/goal-advisory\.js'\)/, 'db.js imports the shared helper');
111
- assert.match(dbSrc, /title = \$2 LIMIT 1/, 'the SQL binds the title rather than inlining it');
111
+ assert.match(dbSrc, /g\.title = \$2/, 'the SQL binds the title rather than inlining it');
112
112
  assert.equal(
113
113
  (dbSrc.match(/' — general'/g) || []).length, 0,
114
114
  'the em-dash literal must exist in exactly ONE place (goal-advisory.js), not in db.js too'
@@ -28,9 +28,20 @@ async function ta(name, fn) {
28
28
  const STALE = 1000;
29
29
 
30
30
  // ── classifyLand truth table ─────────────────────────────────────────────────────────
31
- t('shipped → emit (even when stale)', () => {
31
+ t('shipped inside the watch window → emit', () => {
32
32
  assert.equal(classifyLand({ status: 'shipped' }, 10, { staleMs: STALE }), 'emit');
33
- assert.equal(classifyLand({ status: 'shipped' }, 999999, { staleMs: STALE }), 'emit');
33
+ });
34
+ // task 1003466 — the card-spam bug. 'emit' used to beat 'stale', so a breadcrumb for a
35
+ // task that landed days ago still rendered a "Shipped" banner. With watch() emitting one
36
+ // card per run, a backlog of N stale crumbs cost the builder N prompt-turns of stale news.
37
+ t('shipped but PAST the watch window → drop (a land the builder moved past is not news)', () => {
38
+ assert.equal(classifyLand({ status: 'shipped' }, 999999, { staleMs: STALE }), 'drop');
39
+ assert.equal(classifyLand({ status: 'shipped' }, STALE + 1, { staleMs: STALE }), 'drop');
40
+ assert.equal(classifyLand({ status: 'shipped' }, STALE, { staleMs: STALE }), 'emit', 'exactly at the boundary still emits');
41
+ });
42
+ t('an unknown age → drop (an unparseable breadcrumb must not be immortal)', () => {
43
+ assert.equal(classifyLand({ status: 'shipped' }, Infinity, { staleMs: STALE }), 'drop');
44
+ assert.equal(classifyLand({ status: 'confirmed' }, NaN, { staleMs: STALE }), 'drop');
34
45
  });
35
46
  t('confirmed (still landing) → keep', () => {
36
47
  assert.equal(classifyLand({ status: 'confirmed' }, 10, { staleMs: STALE }), 'keep');
@@ -97,7 +108,41 @@ await ta('emits the NEWEST shipped card, keeps confirmed, drops the rest — one
97
108
  assert.equal(env.title, 'task_3_card', 'newest shipped wins');
98
109
  assert.ok(removed.includes('f3'), 'the emitted crumb is cleared');
99
110
  assert.ok(!removed.includes('f2'), 'the still-confirmed crumb is kept');
100
- assert.ok(!removed.includes('f1'), 'the 2nd shipped waits for the next turn (one card per run)');
111
+ assert.ok(removed.includes('f1'), 'the 2nd shipped is drained in the SAME pass, not held for another turn');
112
+ assert.ok(/task 1\b/.test((env.warnings || []).join(' ')), 'the 2nd shipped is folded into one warning line');
113
+ });
114
+ // task 1003466 — the whole backlog drains in ONE pass. Before this, five landed tasks cost
115
+ // five prompt-turns of cards; now it is one card plus a single "also landed" line.
116
+ await ta('a backlog of shipped crumbs drains in one pass: one card + one also-landed line', async () => {
117
+ const removed = [];
118
+ const crumbs = ['1', '2', '3', '4', '5'].map((id, i) => ({
119
+ taskId: id, at: `2026-07-01T00:00:0${i + 1}.000Z`, _file: `f${id}`, cardInputs: {},
120
+ }));
121
+ const env = await watch({
122
+ nowMs: Date.parse('2026-07-01T00:00:05.500Z'),
123
+ staleMs: 60_000,
124
+ getTask: async () => ({ status: 'shipped' }),
125
+ readCrumbs: () => crumbs,
126
+ removeCrumb: (f) => removed.push(f),
127
+ });
128
+ assert.equal(env.title, 'task_5_card', 'the newest land is the one that gets a card');
129
+ assert.equal(removed.length, 5, 'every crumb is cleared in this single pass');
130
+ const warn = (env.warnings || []).join(' ');
131
+ for (const id of ['1', '2', '3', '4']) assert.ok(warn.includes(`task ${id}`), `task ${id} named in the warning`);
132
+ assert.ok(!warn.includes('task 5'), 'the carded task is not repeated in the warning');
133
+ assert.ok(!/#d/.test(warn), 'never a bare #NNN in emitted prose (it autolinks to a GitHub issue)');
134
+ });
135
+ await ta('stale shipped crumbs are dropped silently — no card, no warning', async () => {
136
+ const removed = [];
137
+ const env = await watch({
138
+ nowMs: Date.parse('2026-07-08T00:00:00.000Z'), // a week later
139
+ staleMs: 30 * 60 * 1000,
140
+ getTask: async () => ({ status: 'shipped' }),
141
+ readCrumbs: () => [{ taskId: '77', at: '2026-07-01T00:00:00.000Z', _file: 'f77', cardInputs: {} }],
142
+ removeCrumb: (f) => removed.push(f),
143
+ });
144
+ assert.equal(env, null, 'a week-old land renders nothing');
145
+ assert.ok(removed.includes('f77'), 'and the crumb is cleared so it never returns');
101
146
  });
102
147
  await ta('no shipped → no envelope; drops the give-ups', async () => {
103
148
  const removed = [];
@@ -12,6 +12,8 @@
12
12
  // - null versionId → null and NO SQL (nothing to resolve).
13
13
  // - a version id → exactly one SELECT keyed on (version_id, the spaced
14
14
  // em-dash title match), bound param [versionId], returning the id or null.
15
+ // - a CLOSED version (shipped/frozen) → null even when the goal exists, so
16
+ // the create lands goal-less rather than in a tomb (task 1003061).
15
17
  //
16
18
  // routes/tasks.js — the router still BUILDS after the PATCH /tasks/:id goal_id
17
19
  // extension (imports resolve; the factory returns a router). The SQL + the
@@ -29,6 +31,10 @@ process.env.NODE_ENV = 'test';
29
31
  const require = createRequire(import.meta.url);
30
32
  const db = require('../modules/lifecycle/db.js');
31
33
  const { generalGoalIdForVersion } = db;
34
+ // versionAcceptsNewWork is INTERNAL to the tasks slice (task 1003061): db.js is the
35
+ // facade for the call sites, and re-exporting a predicate nothing outside this file
36
+ // calls would only widen the facade. Required from where it lives instead.
37
+ const { versionAcceptsNewWork } = require('../modules/lifecycle/db-tasks.js');
32
38
  const buildTasksRouter = require('../modules/lifecycle/routes/tasks.js');
33
39
 
34
40
  const { test, summary } = makeRunner();
@@ -55,17 +61,21 @@ await test('null / undefined versionId → null, and NO SQL is issued', async ()
55
61
  }
56
62
  });
57
63
 
58
- await test('a version with a general goal → returns its id via one SELECT on the em-dash title', async () => {
59
- const pool = fakePool([{ id: 3 }]);
64
+ await test('a BUILDING version with a general goal → returns its id via one SELECT on the em-dash title', async () => {
65
+ const pool = fakePool([{ id: 3, version_status: 'building' }]);
60
66
  const out = await generalGoalIdForVersion('BONGOS-V1', { pool });
61
67
 
62
68
  assert.equal(out, 3, 'returns the general goal id');
63
69
  assert.equal(pool.queries.length, 1, 'exactly one SELECT');
64
70
  const q = pool.queries[0];
71
+ // task 1003061 (BV1.R03): the lookup now JOINs versions and brings the version
72
+ // status back, so the resolver can refuse a closed version's catch-all.
73
+ assert.match(q.sql, /JOIN versions/, 'the lookup reaches the version, not just the goal');
74
+ assert.match(q.sql, /AS version_status/, 'and carries its status back to be judged');
65
75
  // task 1002539: the title is no longer inlined in SQL — it is BOUND, built by the
66
76
  // shared goal-advisory.generalGoalTitle so this lookup and the orphan advisory that
67
77
  // names the same goal cannot drift apart.
68
- assert.match(q.sql, /SELECT id FROM goals WHERE version_id = \$1 AND title = \$2/,
78
+ assert.match(q.sql, /WHERE g\.version_id = \$1 AND g\.title = \$2/,
69
79
  'keyed on version_id + a BOUND title (one definition, in goal-advisory.js)');
70
80
  assert.match(q.sql, /LIMIT 1/, 'single row');
71
81
  assert.deepEqual(q.params, ['BONGOS-V1', 'BONGOS-V1 — general'],
@@ -81,7 +91,7 @@ await test('a version with no general goal → null (caller leaves goal_id NULL,
81
91
  });
82
92
 
83
93
  await test('the em-dash in the title match is U+2014 (not a hyphen/en-dash) — or the backfill silently matches nothing', async () => {
84
- const pool = fakePool([{ id: 9 }]);
94
+ const pool = fakePool([{ id: 9, version_status: 'building' }]);
85
95
  await generalGoalIdForVersion('V2', { pool });
86
96
  // The em-dash moved from the SQL text into the BOUND param (task 1002539); assert
87
97
  // it there, which is stricter — the exact string Postgres compares.
@@ -92,6 +102,54 @@ await test('the em-dash in the title match is U+2014 (not a hyphen/en-dash) —
92
102
  assert.ok(!title.includes(' – general'), 'not an en dash');
93
103
  });
94
104
 
105
+ // =========================================================================
106
+ // task 1003061 (BV1.R03) — the catch-all must never be a TOMB.
107
+ //
108
+ // The 1000771 failure: Discord #bugs hardcoded GDS-V3, a SHIPPED version, so every
109
+ // reported bug resolved into "GDS-V3 — general" and was filed where nobody would look
110
+ // again. routeIdeaToGoal (BV1.R01) already refuses an EXPLICIT goal on a closed
111
+ // version; this closes the IMPLICIT door — the goal-less fallback that every other
112
+ // create vector (POST /tasks, capture, newcomer-restock, seeds) walks through.
113
+ //
114
+ // A closed version's catch-all resolves to null, so createTask leaves goal_id NULL and
115
+ // the orphan advisory (goal-advisory.js) speaks up. Loud silence beats a quiet tomb.
116
+
117
+ await test('versionAcceptsNewWork: only a version still open for work can receive it', () => {
118
+ assert.equal(versionAcceptsNewWork('building'), true, 'building — the live case');
119
+ assert.equal(versionAcceptsNewWork('planning'), true, 'planning — a legitimate future home');
120
+ assert.equal(versionAcceptsNewWork('shipped'), false, 'shipped — the 1000771 tomb');
121
+ assert.equal(versionAcceptsNewWork('frozen'), false, 'frozen — closed to new work');
122
+ });
123
+
124
+ await test('versionAcceptsNewWork fails CLOSED on anything it does not recognise', () => {
125
+ for (const v of [null, undefined, '', 'BUILDING', 'archived', 42, {}]) {
126
+ assert.equal(versionAcceptsNewWork(v), false, `refuses ${JSON.stringify(v)}`);
127
+ }
128
+ });
129
+
130
+ await test("a SHIPPED version's catch-all does not resolve — the create lands goal-less, not in a tomb", async () => {
131
+ const pool = fakePool([{ id: 3, version_status: 'shipped' }]);
132
+ const out = await generalGoalIdForVersion('GDS-V3', { pool });
133
+ assert.equal(out, null, 'the goal EXISTS and is still refused — that is the whole point');
134
+ assert.equal(pool.queries.length, 1, 'it did look — it just declined what it found');
135
+ });
136
+
137
+ await test("a FROZEN version's catch-all does not resolve either", async () => {
138
+ const pool = fakePool([{ id: 4, version_status: 'frozen' }]);
139
+ assert.equal(await generalGoalIdForVersion('OLD-V1', { pool }), null);
140
+ });
141
+
142
+ await test("a PLANNING version's catch-all still resolves (a future home is a real home)", async () => {
143
+ const pool = fakePool([{ id: 5, version_status: 'planning' }]);
144
+ assert.equal(await generalGoalIdForVersion('NEXT-V1', { pool }), 5);
145
+ });
146
+
147
+ await test('a row with no readable version status resolves to null (fail closed)', async () => {
148
+ const pool = fakePool([{ id: 6 }]);
149
+ assert.equal(await generalGoalIdForVersion('MYSTERY-V1', { pool }), null,
150
+ 'an unjoinable or absent version is not a home');
151
+ });
152
+
95
153
  // -------------------------------------------------------------------------
96
154
  await test('routes/tasks.js still builds after the goal_id PATCH extension', () => {
97
155
  assert.equal(typeof buildTasksRouter, 'function', 'module exports the router factory');