@bongos/core 1.20.82 → 1.20.83

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.20.82",
6
- "core_contract": "1.20.82",
7
- "source_commit": "87ffa8207de98da4226b37da37fb0eb85e7731a6",
5
+ "core_version": "1.20.83",
6
+ "core_contract": "1.20.83",
7
+ "source_commit": "980e604cbc632354d35c59435b102b7a3d89310f",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-10-01T22:05:43.104Z",
9
+ "built_at": "2026-10-01T22:31:25.403Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
12
  "docs_redacted": 576,
@@ -17,7 +17,7 @@
17
17
  "gate": "passed"
18
18
  },
19
19
  "file_count": 3430,
20
- "tree_sha256": "4097521e10162eeff35fc3172d0b5265410a17fc910c2ac7ac4949903d217c42",
20
+ "tree_sha256": "d2ce3028f6211f9c9dd38c0961cd5268376abe20dacd81ecba630b90c3354a94",
21
21
  "files": [
22
22
  {
23
23
  "path": ".claude/skills/ask-for-help/SKILL.md",
@@ -2817,7 +2817,7 @@
2817
2817
  {
2818
2818
  "path": "docs/module-api-changelog.md",
2819
2819
  "mode": "0000644",
2820
- "sha256": "ef5bd03a518b2fcc1cebdf63dba01b63de7deb6d531143ba97063fa33a3ac3a3"
2820
+ "sha256": "b404ba3341028a75274dbb74618dfdfaa9b1c5ba13cd5ad127d20c8d9c6bd813"
2821
2821
  },
2822
2822
  {
2823
2823
  "path": "docs/modules-contract.md",
@@ -6657,7 +6657,7 @@
6657
6657
  {
6658
6658
  "path": "modules/lifecycle/done-when.js",
6659
6659
  "mode": "0000644",
6660
- "sha256": "d18ac68962df9ad12f4eb8a51d70b50e68cd9d2e5ac24b400259351dede3883a"
6660
+ "sha256": "bd5dc2082f80376369596c6d8615f134e064dc8689085c31b57a9fd199ffd821"
6661
6661
  },
6662
6662
  {
6663
6663
  "path": "modules/lifecycle/est-advisory.js",
@@ -9542,12 +9542,12 @@
9542
9542
  {
9543
9543
  "path": "package-lock.json",
9544
9544
  "mode": "0000644",
9545
- "sha256": "f080d5a42b53ea4e940ff9410b6ffb709703164fbb20b47f2c11a5abc5c5bbc4"
9545
+ "sha256": "cf7fdf27d152b1b848b391c4faf64692b6f22afb3ead4a273de774ef3a996e67"
9546
9546
  },
9547
9547
  {
9548
9548
  "path": "package.json",
9549
9549
  "mode": "0000644",
9550
- "sha256": "d9cd9956d752e1b71cfe23ec0191aa7f81a74260874170d13139492d459a4006"
9550
+ "sha256": "dcfd94f662fbcece5bfa0fd1202eaadc8a439a08b30728b63473ab1ffb535531"
9551
9551
  },
9552
9552
  {
9553
9553
  "path": "public-docs/index.html",
@@ -9567,7 +9567,7 @@
9567
9567
  {
9568
9568
  "path": "release-notes.json",
9569
9569
  "mode": "0000644",
9570
- "sha256": "da9deaaa50bacb5c80e5eaed36f56fe09d7e0d187c7c77a740c17fe3cc3304e3"
9570
+ "sha256": "0fb50a613d2c0c5c5feba19216d13fb2811f168c3ab0f94f83a8353c37b60ada"
9571
9571
  },
9572
9572
  {
9573
9573
  "path": "scripts/bongos-mcp.js",
@@ -11767,7 +11767,7 @@
11767
11767
  {
11768
11768
  "path": "src/module-api.js",
11769
11769
  "mode": "0000644",
11770
- "sha256": "a9835dd10adca10d6213ab8810b47023ac1a88fc1d917c4cd57d1feec82b0d59"
11770
+ "sha256": "729dd2a3712dc8626174358db32d768a2e00f7349029021dcd37bf0f7db429db"
11771
11771
  },
11772
11772
  {
11773
11773
  "path": "src/module-loader/catalog.js",
@@ -12212,7 +12212,7 @@
12212
12212
  {
12213
12213
  "path": "tests/auto_satisfy_criteria.mjs",
12214
12214
  "mode": "0000644",
12215
- "sha256": "aa0194519c4caf41959e316910d449dfcfaa0b1922f779b9078eccedffc5a234"
12215
+ "sha256": "2ad1e7f9f3699f56ce3a6a5e97a312b99a20a20028d96742e9734ac19d801865"
12216
12216
  },
12217
12217
  {
12218
12218
  "path": "tests/autobongos_cadence.mjs",
@@ -13412,7 +13412,7 @@
13412
13412
  {
13413
13413
  "path": "tests/goal_closure_invariants.mjs",
13414
13414
  "mode": "0000644",
13415
- "sha256": "e0092c01a169485be1862812f3b79a258a81ea7a66cf569a1130c387141d2be0"
13415
+ "sha256": "d14224d4b727e646cbd73d02b3dcab85694021f439af99401aefd5e84a4164cc"
13416
13416
  },
13417
13417
  {
13418
13418
  "path": "tests/goal_conflicts.mjs",
@@ -2821,5 +2821,7 @@ is load-bearing: the script throws rather than guess if it is missing, and
2821
2821
  landed since 1.20.80 with no explicit bump. run 36928688478. (task 1002620)
2822
2822
  1.20.82 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2823
2823
  landed since 1.20.81 with no explicit bump. run 36932856805. (task 1002620)
2824
+ 1.20.83 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2825
+ landed since 1.20.82 with no explicit bump. run 36935503851. (task 1002620)
2824
2826
  ---------------------------------------------------------------------------
2825
2827
  ```
@@ -518,42 +518,91 @@ async function autoSatisfyShippedCriteria(exec, { taskId = null, criterionId = n
518
518
  c.satisfied_by_task_id`,
519
519
  [scopedTaskId, scopedCriterionId]
520
520
  );
521
- if (criteria.length === 0) return { criteria: [], goals: [] };
521
+ // THE SHIP THAT FLIPS NO CRITERION STILL FINISHES ITS GOAL (task 1004513).
522
+ // Returning here unconditionally was a hole. When every criterion of a goal was
523
+ // satisfied BEFORE its last task shipped, the UPDATE above matches nothing — and
524
+ // the other writer (`POST /done-when/:id/satisfy` → achieveGoalIfComplete) had
525
+ // already run too early, while that task was still open, so it correctly held
526
+ // the goal open and then nobody ever re-asked. The goal sat `open` with every
527
+ // criterion satisfied and no work left, until a criterion was satisfied a second
528
+ // time purely to re-trigger the other door.
529
+ //
530
+ // So on the SCOPED ship path, re-check the shipping task's OWN goal against the
531
+ // same rule the cascade below applies. `exec` is load-bearing here for the same
532
+ // reason it is there: this task's flip to `shipped` is uncommitted, and on the
533
+ // module pool the task check would still read `confirmed` — non-terminal — and
534
+ // hold the goal open exactly as before.
535
+ //
536
+ // The UNSCOPED sweep keeps its cheap early return: it holds no task, so there is
537
+ // no candidate goal to name, and every goal it can close it closes through the
538
+ // cascade below.
539
+ if (criteria.length === 0) {
540
+ if (scopedTaskId == null) return { criteria: [], goals: [] };
541
+ const { rows: own } = await exec.query(
542
+ `SELECT goal_id FROM tasks WHERE id = $1::bigint AND goal_id IS NOT NULL`,
543
+ [scopedTaskId]
544
+ );
545
+ const goals = await achieveCompletedGoals(exec, own.map((r) => Number(r.goal_id)));
546
+ return { criteria: [], goals, versionsClosed: await autoCloseVersionsForGoals(exec, goals) };
547
+ }
522
548
 
523
549
  // Cascade. Deliberately re-derived from the table rather than from `criteria`:
524
550
  // a goal completes when ALL of its criteria are satisfied, and the ones that
525
- // closed in THIS call may not be all of them. Mirrors db.achieveGoalIfComplete
526
- // exactly — open goals only, >=1 criterion, zero unsatisfied, AND no unfinished
527
- // task (BV1.R09, task 1003596) — but on `exec`, so it sees the flips above
528
- // inside the same transaction.
529
- //
530
- // THE THIRD CLAUSE, and why it must run HERE and not only in db-goals.js. This
531
- // is the path that actually fires on every ship (`db-ship.js`) and every
532
- // reconciler tick (`publish-reconciler.js`); `achieveGoalIfComplete` covers the
533
- // manual `POST /done-when/:id/satisfy`. The two are a DELIBERATE duplicate —
534
- // collapsing them into one helper creates a require cycle (db-goals requires
535
- // done-when) — so the rule is written twice on purpose and both copies must
536
- // move together, or a goal closes correctly through one door and wrongly
537
- // through the other. ADR 0250 D2.
538
- //
539
- // `exec` is load-bearing for THIS clause specifically. The shipping task's flip
540
- // to `shipped` is uncommitted at this point in the ship transaction; on the
541
- // module pool the subquery would still see it as `confirmed` — non-terminal —
542
- // and refuse to close a goal whose last task just landed. Every goal would then
543
- // need a second, later trigger to close at all.
544
- //
545
- // Shipping is NOT blocked by any of this: the ship's own writes are untouched
546
- // and this UPDATE simply matches no row, leaving the goal open.
547
- //
548
- // The subquery is index-served: migration 228 added idx_tasks_goal ON tasks
549
- // (goal_id), so this is a small index scan per candidate goal, and the candidate
550
- // set is only the goals whose criteria just closed in this call. (Raised in
551
- // review off docs/architecture.md, whose tasks index list predated 228 and has
552
- // been corrected.)
551
+ // closed in THIS call may not be all of them. The statement itself is
552
+ // achieveCompletedGoals, below.
553
553
  const goalIds = [...new Set(criteria.map((c) => c.goal_id).filter((g) => g != null))].map(Number);
554
554
  // No goal achieved, so no version can have just become closable.
555
555
  if (goalIds.length === 0) return { criteria, goals: [], versionsClosed: [] };
556
- const { rows: goals } = await exec.query(
556
+ const goals = await achieveCompletedGoals(exec, goalIds);
557
+ // BV1.R16 (task 1003603, ADR 0263 §§3–4): a version closes ITSELF once its last
558
+ // non-maintenance goal achieves. On the caller's `exec`, so it sees the flips
559
+ // above that have not committed yet — a check on the pool would never close the
560
+ // version on the ship that actually finished it.
561
+ //
562
+ // `autoCloseVersionsForGoals` catches per version and never rethrows: a version
563
+ // close must not be able to fail a builder's ship. The accepted failure is a
564
+ // version left un-closed, which the reconciler's next unscoped sweep heals.
565
+ const versionsClosed = await autoCloseVersionsForGoals(exec, goals);
566
+ return { criteria, goals, versionsClosed };
567
+ }
568
+
569
+ // achieveCompletedGoals — the ONE statement that flips a goal open→achieved on
570
+ // the ship/sweep side. Written once because autoSatisfyShippedCriteria now enters
571
+ // it from two places — the criteria this call just closed, and (task 1004513) the
572
+ // shipping task's own goal when it closed none — and two copies of this rule
573
+ // would be two things to keep in step. Takes the caller's `exec` and the candidate
574
+ // goal ids; returns the rows that actually flipped (usually none).
575
+ //
576
+ // Mirrors db.achieveGoalIfComplete exactly — open goals only, >=1 criterion, zero
577
+ // unsatisfied, AND no unfinished task (BV1.R09, task 1003596).
578
+ //
579
+ // THE THIRD CLAUSE, and why it must run HERE and not only in db-goals.js. This is
580
+ // the path that actually fires on every ship (`db-ship.js`) and every reconciler
581
+ // tick (`publish-reconciler.js`); `achieveGoalIfComplete` covers the manual
582
+ // `POST /done-when/:id/satisfy`. The two are a DELIBERATE duplicate — collapsing
583
+ // them into one helper creates a require cycle (db-goals requires done-when) — so
584
+ // the rule is written twice on purpose and both copies must move together, or a
585
+ // goal closes correctly through one door and wrongly through the other. ADR 0250
586
+ // D2.
587
+ //
588
+ // `exec` is load-bearing for THIS clause specifically. The shipping task's flip
589
+ // to `shipped` is uncommitted at this point in the ship transaction; on the module
590
+ // pool the subquery would still see it as `confirmed` — non-terminal — and refuse
591
+ // to close a goal whose last task just landed. Every goal would then need a
592
+ // second, later trigger to close at all.
593
+ //
594
+ // Shipping is NOT blocked by any of this: the ship's own writes are untouched and
595
+ // this UPDATE simply matches no row, leaving the goal open.
596
+ //
597
+ // The subquery is index-served: migration 228 added idx_tasks_goal ON tasks
598
+ // (goal_id), so this is a small index scan per candidate goal, and the candidate
599
+ // set is only the goals this call could possibly have completed. (Raised in review
600
+ // off docs/architecture.md, whose tasks index list predated 228 and has been
601
+ // corrected.)
602
+ async function achieveCompletedGoals(exec, goalIds) {
603
+ const ids = (goalIds || []).filter((g) => g != null).map(Number);
604
+ if (ids.length === 0) return [];
605
+ const { rows } = await exec.query(
557
606
  `UPDATE goals g
558
607
  SET status = 'achieved', updated_at = now()
559
608
  WHERE g.id = ANY($1::bigint[])
@@ -564,21 +613,12 @@ async function autoSatisfyShippedCriteria(exec, { taskId = null, criterionId = n
564
613
  AND ${nonTerminalSql()}
565
614
  AND ${SMOKE_NOT_LIKE.replace(/title/, 't.title')})
566
615
  RETURNING g.id, g.title, g.version_id, g.status`,
567
- [goalIds]
616
+ [ids]
568
617
  );
569
618
  // Task 1003660: an achieved goal takes no new members — close its pending asks
570
619
  // on the same executor, so the cancel rides the ship transaction.
571
- await cancelPendingMembershipRequests(exec, goals.map((g) => g.id));
572
- // BV1.R16 (task 1003603, ADR 0263 §§3–4): a version closes ITSELF once its last
573
- // non-maintenance goal achieves. On the caller's `exec`, so it sees the flips
574
- // above that have not committed yet — a check on the pool would never close the
575
- // version on the ship that actually finished it.
576
- //
577
- // `autoCloseVersionsForGoals` catches per version and never rethrows: a version
578
- // close must not be able to fail a builder's ship. The accepted failure is a
579
- // version left un-closed, which the reconciler's next unscoped sweep heals.
580
- const versionsClosed = await autoCloseVersionsForGoals(exec, goals);
581
- return { criteria, goals, versionsClosed };
620
+ await cancelPendingMembershipRequests(exec, rows.map((g) => g.id));
621
+ return rows;
582
622
  }
583
623
 
584
624
  // describeAutoClose — the one-line summary of what a close actually closed, or
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.20.82",
3
+ "version": "1.20.83",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.20.82",
9
+ "version": "1.20.83",
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.20.82",
3
+ "version": "1.20.83",
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",
@@ -8653,5 +8653,11 @@
8653
8653
  "id": "1004509",
8654
8654
  "text": "The Autobongos runner panel on the Gate page is now much lighter to read. While anything is missing it shows a short checklist saying what to do next; once everything is ready it shrinks to one status line with an on/off switc"
8655
8655
  }
8656
+ ],
8657
+ "1.20.83": [
8658
+ {
8659
+ "id": "1004513",
8660
+ "text": "A goal now finishes by itself when its last task ships. Before, if someone ticked off all of a goal's done-when criteria while one task was still being worked on, the goal stayed open forever once that task landed — it took ti"
8661
+ }
8656
8662
  ]
8657
8663
  }
package/src/module-api.js CHANGED
@@ -75,7 +75,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
75
75
  // MAJOR (see allowBoxScope below): passes the request through untouched.
76
76
  function deprecatedNoopMiddleware(_req, _res, next) { next(); }
77
77
 
78
- const CORE_VERSION = '1.20.82'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
78
+ const CORE_VERSION = '1.20.83'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
79
79
 
80
80
  // A namespaced logger so a module's log lines are attributable + consistent.
81
81
  // Usage: const log = api.logger('discord'); log.info('mounted');
@@ -158,6 +158,26 @@ await test('NO-OP: nothing closed means no cascade query at all', async () => {
158
158
  assert.equal(exec.calls.length, 1, 'must not run the goal cascade when no criterion moved');
159
159
  });
160
160
 
161
+ await test('NO-OP (scoped): a ship that flips no criterion still re-asks its OWN goal (task 1004513)', async () => {
162
+ // When every criterion was satisfied BEFORE the last task shipped, the ship
163
+ // flips none — and the early return above used to mean nothing re-checked the
164
+ // goal, which then sat `open` with all its work done (goal 1000119, 2026-10-01).
165
+ const exec = fakeExec(
166
+ { rows: [] },
167
+ { rows: [{ goal_id: 77 }] },
168
+ { rows: [{ id: 77, title: 'g', version_id: 'BONGOS-V1', status: 'achieved' }] }
169
+ );
170
+ const out = await autoSatisfyShippedCriteria(exec, { taskId: 5 });
171
+ assert.deepEqual(out.criteria, [], 'no criterion moved — ADR 0183 is untouched');
172
+ assert.deepEqual(out.goals.map((g) => g.id), [77], 'but the goal closes on the ship that finished it');
173
+ assert.match(exec.calls[1].sql, /SELECT goal_id FROM tasks WHERE id = \$1::bigint/, 'the shipping task names the candidate');
174
+ assert.deepEqual(exec.calls[1].params, [5]);
175
+ assert.match(String(exec.calls[2].sql), /UPDATE goals g/, 'and it runs the SAME cascade statement');
176
+ assert.match(String(exec.calls[2].sql).replace(/\s+/g, ' '), /NOT EXISTS \(SELECT 1 FROM tasks t WHERE t\.goal_id = g\.id/,
177
+ 'carrying R09 — this entry may not be a looser rule than the cascade');
178
+ assert.deepEqual(exec.calls[2].params, [[77]], 'scoped to that one goal, nothing wider');
179
+ });
180
+
161
181
  await test('CASCADE: only OPEN goals, only with >=1 criterion and zero unsatisfied', async () => {
162
182
  const exec = fakeExec({ rows: [CRIT] }, { rows: [] });
163
183
  await autoSatisfyShippedCriteria(exec, { taskId: 5 });
@@ -95,6 +95,14 @@ function makeExec(w) {
95
95
  return { rows: closed.map((c) => ({ ...c, satisfied_by_task_id: scoped })) };
96
96
  }
97
97
 
98
+ // (1b) task 1004513: the shipping task's OWN goal, asked only when the ship
99
+ // flipped no criterion — the case where every criterion was already
100
+ // satisfied and the goal has nothing left but this task.
101
+ if (/SELECT goal_id FROM tasks WHERE id = \$1/.test(s)) {
102
+ const t = taskById(Number(params[0]));
103
+ return { rows: t && t.goal_id != null ? [{ goal_id: t.goal_id }] : [] };
104
+ }
105
+
98
106
  // (2) the cascade. NOT hand-coded: the model reads the statement it was
99
107
  // actually handed and honours only the clauses PRESENT in it — including
100
108
  // the terminal set, parsed out of the SQL rather than imported. That is
@@ -265,6 +273,66 @@ test('every statement goes through the caller exec — none reaches the module p
265
273
  assert.match(exec.calls[3].sql, /UPDATE versions v/);
266
274
  });
267
275
 
276
+ // ---------------------------------------------------------------------------
277
+ // 2b. The ship that flips NO criterion still finishes its goal (task 1004513)
278
+ //
279
+ // The hole this pins: satisfy every criterion of a goal while one of its tasks is
280
+ // still open. The manual door (achieveGoalIfComplete, off POST /done-when/:id/
281
+ // satisfy) runs then and correctly declines — a task is unfinished. When that
282
+ // task later ships, the cascade flips no criterion (they are already satisfied),
283
+ // and before this fix the no-criteria early return meant the goal was never
284
+ // re-asked: it sat `open` with everything done. Live on goal 1000119, 2026-10-01.
285
+ // ---------------------------------------------------------------------------
286
+
287
+ test('a goal whose criteria were ALL satisfied before its last task shipped still closes', async () => {
288
+ const w = world({ criteria: [{ satisfied: true, task_ids: [200] }], tasks: [SHIPPED()] });
289
+ const exec = makeExec(w);
290
+ const out = await autoSatisfyShippedCriteria(exec, { taskId: 200 });
291
+ assert.deepEqual(out.criteria, [], 'the ship flips no criterion — they were satisfied already');
292
+ assert.equal(out.goals.length, 1, 'and the goal still reaches achieved');
293
+ assert.equal(w.goals[0].status, 'achieved');
294
+ // The wiring, not just the verdict: it asks for the shipping task's own goal
295
+ // and then runs the SAME cascade statement, on the same exec.
296
+ assert.match(exec.calls[0].sql, /UPDATE done_when_criteria/);
297
+ assert.match(exec.calls[1].sql, /SELECT goal_id FROM tasks WHERE id = \$1/);
298
+ assert.match(exec.calls[2].sql, /UPDATE goals g/);
299
+ assert.deepEqual(exec.calls[2].params, [[77]], 'scoped to the shipping task\'s goal, nothing wider');
300
+ assert.match(exec.calls[3].sql, /UPDATE goal_membership_requests/, 'an achieved goal closes its pending asks on this path too (task 1003660)');
301
+ assert.match(exec.calls[4].sql, /UPDATE versions v/, 'and the version close still rides along');
302
+ });
303
+
304
+ for (const status of HOLDS_OPEN) {
305
+ test(`the no-criterion re-check honours the same rule: a '${status}' sibling still holds the goal open`, async () => {
306
+ const w = world({ criteria: [{ satisfied: true, task_ids: [200] }], tasks: [SHIPPED(), { status }] });
307
+ const out = await autoSatisfyShippedCriteria(makeExec(w), { taskId: 200 });
308
+ assert.deepEqual(out.goals, [], 'the re-check is the rule, not a bypass of it');
309
+ assert.equal(w.goals[0].status, 'open');
310
+ });
311
+ }
312
+
313
+ test('the no-criterion re-check reads through the exec it was handed', async () => {
314
+ // Same instant, two views. Inside the ship transaction task 200 is already
315
+ // `shipped`; on the module pool it is still `confirmed` — non-terminal — and
316
+ // the goal must not close. The pair is what proves the read goes through exec.
317
+ const inTxn = world({ criteria: [{ satisfied: true, task_ids: [200] }], tasks: [SHIPPED()] });
318
+ const outsideTxn = world({ criteria: [{ satisfied: true, task_ids: [200] }], tasks: [{ status: 'confirmed' }] });
319
+ assert.equal((await autoSatisfyShippedCriteria(makeExec(inTxn), { taskId: 200 })).goals.length, 1);
320
+ assert.deepEqual((await autoSatisfyShippedCriteria(makeExec(outsideTxn), { taskId: 200 })).goals, []);
321
+ assert.equal(outsideTxn.goals[0].status, 'open');
322
+ });
323
+
324
+ test('a goal-less task closes nothing, and the UNSCOPED sweep keeps its cheap early return', async () => {
325
+ const orphan = world({ criteria: [{ satisfied: true, task_ids: [200] }], tasks: [SHIPPED({ goal_id: null })] });
326
+ const orphanExec = makeExec(orphan);
327
+ assert.deepEqual((await autoSatisfyShippedCriteria(orphanExec, { taskId: 200 })).goals, []);
328
+ assert.equal(orphanExec.calls.length, 2, 'it asks for the goal, finds none, and stops');
329
+
330
+ const sweep = makeExec(world({ criteria: [{ satisfied: true, task_ids: [200] }], tasks: [SHIPPED()] }));
331
+ const out = await autoSatisfyShippedCriteria(sweep, {});
332
+ assert.deepEqual(out, { criteria: [], goals: [] }, 'the sweep holds no task, so it has no goal to name');
333
+ assert.equal(sweep.calls.length, 1, 'and issues no second statement');
334
+ });
335
+
268
336
  // ---------------------------------------------------------------------------
269
337
  // 3. A ship still LANDS while its goal is held open
270
338
  // ---------------------------------------------------------------------------