@bongos/core 1.19.986 → 1.19.987

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,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.986",
6
- "core_contract": "1.19.986",
7
- "source_commit": "df54cfbda9edb7e3e5b7f953174e31e6a5b65bc4",
5
+ "core_version": "1.19.987",
6
+ "core_contract": "1.19.987",
7
+ "source_commit": "102ad9a7c1c780717451cc48241d577442dc44f1",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-09-23T18:36:30.871Z",
9
+ "built_at": "2026-09-23T19:04:45.567Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
12
  "docs_redacted": 518,
13
13
  "agent_docs_stubbed": 26,
14
- "functional_verbatim": 2390,
14
+ "functional_verbatim": 2391,
15
15
  "rules": 3,
16
16
  "gate_literals": 3,
17
17
  "gate": "passed"
18
18
  },
19
- "file_count": 2934,
20
- "tree_sha256": "8b898c04436c76259878da047c512541c0d0a03e9de13419b31e0a0d41ffa182",
19
+ "file_count": 2935,
20
+ "tree_sha256": "e0b9f64be067a45f8a343766f231127d7bb3708dc50656f188e122f06b15ac2a",
21
21
  "files": [
22
22
  {
23
23
  "path": ".claude/skills/ask-for-help/SKILL.md",
@@ -2712,7 +2712,7 @@
2712
2712
  {
2713
2713
  "path": "docs/module-api-changelog.md",
2714
2714
  "mode": "0000644",
2715
- "sha256": "e779420483a2e19ea834a5995d1960296dad434fa158c1e46db3ee4e12b6569e"
2715
+ "sha256": "fcad58b07e48eb75f7dbfb280b1d2f167d07a753388aa063d292c7aee323accf"
2716
2716
  },
2717
2717
  {
2718
2718
  "path": "docs/modules-contract.md",
@@ -5707,7 +5707,7 @@
5707
5707
  {
5708
5708
  "path": "modules/lifecycle/cascade.js",
5709
5709
  "mode": "0000644",
5710
- "sha256": "176f7463d6f3c7bf2297760d0934f88528ee180dfb0c9e49a4347a449af2820d"
5710
+ "sha256": "714bbacc99433c307b949c7f882a081e3dfb77b86c8e472c82a7729b513f7d74"
5711
5711
  },
5712
5712
  {
5713
5713
  "path": "modules/lifecycle/check-rollup.js",
@@ -6092,7 +6092,7 @@
6092
6092
  {
6093
6093
  "path": "modules/lifecycle/routes/lifecycle.js",
6094
6094
  "mode": "0000644",
6095
- "sha256": "330e42fad79812a05a90256fe31931df2156093b80a648d7c838ecac136c91e4"
6095
+ "sha256": "9f311cf078774875e53c4a07ad65501b09d37a3dbfdbe52d38d394ed555100d1"
6096
6096
  },
6097
6097
  {
6098
6098
  "path": "modules/lifecycle/routes/mingle.js",
@@ -6159,6 +6159,11 @@
6159
6159
  "mode": "0000644",
6160
6160
  "sha256": "bc95dfe95261ddcfdda7f578ba2f4e0e9c1f20d7da955ac5d64800476538bed5"
6161
6161
  },
6162
+ {
6163
+ "path": "modules/lifecycle/ship-files.js",
6164
+ "mode": "0000644",
6165
+ "sha256": "7e0797b26e870d33f054708f2ba717589231c8ccada444ebb03c6234452c8d90"
6166
+ },
6162
6167
  {
6163
6168
  "path": "modules/lifecycle/ship-preflight.js",
6164
6169
  "mode": "0000644",
@@ -6197,7 +6202,7 @@
6197
6202
  {
6198
6203
  "path": "modules/lifecycle/tests/cascade.mjs",
6199
6204
  "mode": "0000644",
6200
- "sha256": "fc8c5cf14265515faf8dc63b17b80de1acbb7558a08d26808960244aec6e4377"
6205
+ "sha256": "cd0360521e8f89e86fabdf75f41dc213fd2ae5a0ae5a51548441164a915df292"
6201
6206
  },
6202
6207
  {
6203
6208
  "path": "modules/lifecycle/version-autoclose.js",
@@ -8157,12 +8162,12 @@
8157
8162
  {
8158
8163
  "path": "package-lock.json",
8159
8164
  "mode": "0000644",
8160
- "sha256": "2fcebda4add1d874aee7ce5f756b37bb55485b6c001aaccde5b0f9624d4c0192"
8165
+ "sha256": "80f51079ed98cab29d9b48f5f20049f58104b76d68645ac01d03a7bdcde9f29e"
8161
8166
  },
8162
8167
  {
8163
8168
  "path": "package.json",
8164
8169
  "mode": "0000644",
8165
- "sha256": "80d5c9a1504e44bfccb0e3bfb0fd38e99eed359118d9d48f12bf9cab895f0533"
8170
+ "sha256": "fe62f7efb8e83d82f66dd2277b8ae4da8d448a9286727ea88460ec358f339fc5"
8166
8171
  },
8167
8172
  {
8168
8173
  "path": "public-docs/index.html",
@@ -9532,7 +9537,7 @@
9532
9537
  {
9533
9538
  "path": "scripts/gds/ship-grade.js",
9534
9539
  "mode": "0000644",
9535
- "sha256": "40c6a9f6aa6a75cdf8a5e41bfa8318766636cba64e47aee870c26a719676713d"
9540
+ "sha256": "eb0740f3cdc3dc91038320dd9044dc81c05e31212059d772d5c8bf6e9f44b474"
9536
9541
  },
9537
9542
  {
9538
9543
  "path": "scripts/gds/ship-honesty.js",
@@ -10187,7 +10192,7 @@
10187
10192
  {
10188
10193
  "path": "src/module-api.js",
10189
10194
  "mode": "0000644",
10190
- "sha256": "17399ab568a9343c5c5cead3e1e467af941ec891ee117b424ad0e2ed10151731"
10195
+ "sha256": "19be294e50fce302cf4663c7776dfd8472eaddfb33805baf32b681603271cb77"
10191
10196
  },
10192
10197
  {
10193
10198
  "path": "src/module-loader/catalog.js",
@@ -12037,7 +12042,7 @@
12037
12042
  {
12038
12043
  "path": "tests/grader_reader_lens.mjs",
12039
12044
  "mode": "0000644",
12040
- "sha256": "93e24012252c08798e3017ce9a7130ce9ece4e5e07456892b3632ab12f44ad47"
12045
+ "sha256": "cacfafb2e8408bfcc2137ab1bf11bed21cf95c720aa0a7bc5cdbf0386f4833c8"
12041
12046
  },
12042
12047
  {
12043
12048
  "path": "tests/grader_regrade_delta.mjs",
@@ -2446,5 +2446,7 @@ is load-bearing: the script throws rather than guess if it is missing, and
2446
2446
  landed since 1.19.984 with no explicit bump. run 35902000024. (task 1002620)
2447
2447
  1.19.986 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2448
2448
  landed since 1.19.985 with no explicit bump. run 35903379073. (task 1002620)
2449
+ 1.19.987 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2450
+ landed since 1.19.986 with no explicit bump. run 35906802480. (task 1002620)
2449
2451
  ---------------------------------------------------------------------------
2450
2452
  ```
@@ -126,15 +126,30 @@ const COPY_VISUAL_MODULES = Object.freeze([
126
126
  'art-pipeline', // generated visual assets
127
127
  'status-ui', // the public status dashboard
128
128
  'ui-design', // the design-system surfaces (ADR 0081)
129
+ 'public-landing', // the apex and the project hub (projects.html) — the owner-facing pages
129
130
  ]);
130
131
 
132
+ // A path that IS a rendered surface: a page, sheet, script or image under a
133
+ // module's public/ dir. Explicit for the same reason as the list above — a
134
+ // backend file in a UI module (routes/, db.js, module.json) is not visual work,
135
+ // and neither is a kit states file (.json).
136
+ const UI_SURFACE_PATH = /^modules\/[^/]+\/public\/.+\.(html?|css|m?js|svg|png|jpe?g|webp|gif|avif|ico|woff2?|ttf|otf|mp4|webm)$/i;
137
+ function isUiSurfacePath(p) { return typeof p === 'string' && UI_SURFACE_PATH.test(p); }
138
+
131
139
  // Does this shipped task's work put copy or visuals in front of a person? Pure
132
140
  // and total — the runner treats a throw as "no match", but this must not throw.
133
- function hasCopyOrVisualWork(task) {
141
+ //
142
+ // `changedFiles` is what the work actually CHANGED (task 1004201). The label
143
+ // alone missed most UI work: of 19 recent ships that changed page files, 14 had
144
+ // module_key null — the goal forest (task 1003480) shipped its overflowing
145
+ // titles with no visual lens and no review. Owner decision 2026-09-23: every UI
146
+ // change gets a visual review, and this is the predicate that files it.
147
+ function hasCopyOrVisualWork(task, changedFiles) {
134
148
  if (!task) return false;
135
149
  if (task.source === 'copy-proposal') return true; // a copy change that landed via the copy desk (ADR 0233)
136
150
  if (task.discipline === 'artist') return true; // work an artist did is, by definition, on their surface
137
151
  if (task.module_key && COPY_VISUAL_MODULES.includes(task.module_key)) return true;
152
+ if (Array.isArray(changedFiles) && changedFiles.some(isUiSurfacePath)) return true;
138
153
  return false;
139
154
  }
140
155
 
@@ -235,8 +250,12 @@ const CASCADES = Object.freeze([
235
250
  // is read from ctx, never from branding, so the predicate stays pure; an
236
251
  // absent ctx (a caller that resolved no policy) files, because the gate's
237
252
  // default is 'strict' and silence must not be read as an opt-out.
253
+ //
254
+ // A changed-files list that hit GitHub's read cap FILES (task 1004201): a UI
255
+ // file may sit past the cap, and a padded PR must not skip its review.
238
256
  when: (t, ctx) => (ctx || {}).artistGate !== 'off'
239
- && t.source !== CASCADE_SOURCE && hasCopyOrVisualWork(t),
257
+ && t.source !== CASCADE_SOURCE
258
+ && (hasCopyOrVisualWork(t, (ctx || {}).changedFiles) || (ctx || {}).changedFilesTruncated === true),
240
259
  title: (t) => `Artist review: the copy and visuals that shipped in task ${t.id}`,
241
260
  body: artistReviewBody,
242
261
  }),
@@ -539,6 +558,17 @@ async function runCascadesForShip(shipped, deps = {}) {
539
558
  try { ctx.artistGate = deps.artistGateMode(); }
540
559
  catch (e) { report.errors.push(`cascade: artistGateMode() threw (${e && e.message}) — policy unread`); }
541
560
  }
561
+ // WHAT THIS SHIP CHANGED (task 1004201), from the server's own view of the
562
+ // merged pull request, so the artist review keys on the work rather than a
563
+ // module_key label. A resolver that throws is reported and leaves the key
564
+ // unset: the label-based match still stands and the ship costs nothing.
565
+ if (typeof deps.changedFiles === 'function') {
566
+ try {
567
+ const changed = await deps.changedFiles(task);
568
+ ctx.changedFiles = (changed && changed.paths) || [];
569
+ ctx.changedFilesTruncated = Boolean(changed && changed.truncated);
570
+ } catch (e) { report.errors.push(`cascade: changedFiles() threw (${e && e.message}) — matched on labels only`); }
571
+ }
542
572
  // WHAT THIS SHIP DELETED (task 1004090 / BV2.R15, ADR 0313). Resolved here
543
573
  // rather than in the rule for the reason the rule's own comment gives: a
544
574
  // `when` is pure and synchronous, and this is a network read.
@@ -30,6 +30,7 @@ const db = require('../db');
30
30
  const cascade = require('../cascade');
31
31
  const removalDerivation = require('../removal-derivation');
32
32
  const removalTargets = require('../removal-targets');
33
+ const shipFiles = require('../ship-files');
33
34
 
34
35
  // task 1003549 — the cross-role cascade listener. `task.shipped` already exists
35
36
  // as a kernel event (emitted post-commit by the publish reconciler and the ship
@@ -70,49 +71,60 @@ function registerSeams() {
70
71
  log: api.logger ? api.logger('lifecycle') : null,
71
72
  }));
72
73
 
73
- _cascadeListenerOff = api.on('task.shipped', (shipped) => cascade.runCascadesForShip(shipped, {
74
- getTask: (id) => db.getTask(id),
75
- findTaskBySourceRef: (ref) => db.findTaskBySourceRef(ref),
76
- createTask: (spec) => db.createTask(spec),
77
- // The project's artist gate (ADR 0241, task 1003575). Resolved through the
78
- // doorway's one reader rather than from branding().project, so the level that
79
- // decides whether a review is FILED and the level that decides whether one
80
- // HOLDS a deploy can never be read differently. At 'off' the artist-review
81
- // rule files nothing.
82
- artistGateMode: () => api.artistGate.artistGateMode(),
83
- // WHAT THIS SHIP DELETED (task 1004090 / BV2.R15, ADR 0313). The derivation
84
- // asks GitHub what the pull request actually changed and blames the deleted
85
- // paths back through `claims.published_head_sha` — the SERVER's own view of
86
- // the merge, never the client-supplied `diff_stats` the net-negative bonus
87
- // reads, which is target-less and already known-forgeable.
88
- //
89
- // Resolving the PR number here rather than inside the derivation keeps that
90
- // file free of the task->PR question, which github-push already answers.
91
- // A task with no findable PR derives nothing, which files nothing.
92
- //
93
- // Every failure is swallowed into an empty result: this runs on the ship
94
- // path, and ADR 0313 is explicit that landing work matters more than dimming
95
- // a star on time.
96
- //
97
- // The resolver itself is `removalCandidatesForTask`, NOT a closure here. It
98
- // was a closure, and it read the wrong field off findTaskPullRequest
99
- // (`number` for `pr_number`), which made the whole rule dead in production
100
- // while every test passed — because every test injected this dep. A closure
101
- // inside a listener registration is not reachable from a test; a named
102
- // function is, and this line is now too small to hide a bug of its own.
103
- removalCandidates: (task) => removalDerivation.removalCandidatesForTask(task, {
104
- shippedTasksByPublishedSha: (shas) => db.shippedTasksByPublishedSha(shas),
105
- }),
106
- // WHOSE WORK IT WAS, stored (task 1004099 / BV2.R16 brightness). The
107
- // derivation above already computed the attribution and the proposal body
108
- // renders it as prose; this persists the same objects so brightness can
109
- // read them structured. Passed as the NAMED function rather than wrapped in
110
- // a closure, for the reason the note above gives: a closure here is not
111
- // reachable from a test, and that is how the `number`/`pr_number` bug lived
112
- // in production with a green suite.
113
- recordRemovalTargets: removalTargets.recordRemovalTargets,
114
- log: api.logger ? api.logger('lifecycle') : null,
115
- }));
74
+ _cascadeListenerOff = api.on('task.shipped', (shipped) => {
75
+ // ONE pair of GitHub reads (find the PR, list its files) per ship, shared by
76
+ // the two resolvers below that both need them (task 1004201).
77
+ const pr = shipFiles.sharedPrReads();
78
+ return cascade.runCascadesForShip(shipped, {
79
+ getTask: (id) => db.getTask(id),
80
+ findTaskBySourceRef: (ref) => db.findTaskBySourceRef(ref),
81
+ createTask: (spec) => db.createTask(spec),
82
+ // The project's artist gate (ADR 0241, task 1003575). Resolved through the
83
+ // doorway's one reader rather than from branding().project, so the level that
84
+ // decides whether a review is FILED and the level that decides whether one
85
+ // HOLDS a deploy can never be read differently. At 'off' the artist-review
86
+ // rule files nothing.
87
+ artistGateMode: () => api.artistGate.artistGateMode(),
88
+ // WHAT THIS SHIP CHANGED (task 1004201): the merged PR's paths, so a UI
89
+ // change files its artist review whatever its module_key label says. The
90
+ // NAMED function, not a closure, for the reason removalCandidates gives below;
91
+ // the arrow only hands it this ship's shared reads.
92
+ changedFiles: (task) => shipFiles.changedFilesForTask(task, pr),
93
+ // WHAT THIS SHIP DELETED (task 1004090 / BV2.R15, ADR 0313). The derivation
94
+ // asks GitHub what the pull request actually changed and blames the deleted
95
+ // paths back through `claims.published_head_sha` — the SERVER's own view of
96
+ // the merge, never the client-supplied `diff_stats` the net-negative bonus
97
+ // reads, which is target-less and already known-forgeable.
98
+ //
99
+ // Resolving the PR number here rather than inside the derivation keeps that
100
+ // file free of the task->PR question, which github-push already answers.
101
+ // A task with no findable PR derives nothing, which files nothing.
102
+ //
103
+ // Every failure is swallowed into an empty result: this runs on the ship
104
+ // path, and ADR 0313 is explicit that landing work matters more than dimming
105
+ // a star on time.
106
+ //
107
+ // The resolver itself is `removalCandidatesForTask`, NOT a closure here. It
108
+ // was a closure, and it read the wrong field off findTaskPullRequest
109
+ // (`number` for `pr_number`), which made the whole rule dead in production
110
+ // while every test passed — because every test injected this dep. A closure
111
+ // inside a listener registration is not reachable from a test; a named
112
+ // function is, and this line is now too small to hide a bug of its own.
113
+ removalCandidates: (task) => removalDerivation.removalCandidatesForTask(task, {
114
+ ...pr,
115
+ shippedTasksByPublishedSha: (shas) => db.shippedTasksByPublishedSha(shas),
116
+ }),
117
+ // WHOSE WORK IT WAS, stored (task 1004099 / BV2.R16 brightness). The
118
+ // derivation above already computed the attribution and the proposal body
119
+ // renders it as prose; this persists the same objects so brightness can
120
+ // read them structured. Passed as the NAMED function rather than wrapped in
121
+ // a closure, for the reason the note above gives: a closure here is not
122
+ // reachable from a test, and that is how the `number`/`pr_number` bug lived
123
+ // in production with a green suite.
124
+ recordRemovalTargets: removalTargets.recordRemovalTargets,
125
+ log: api.logger ? api.logger('lifecycle') : null,
126
+ });
127
+ });
116
128
  }
117
129
 
118
130
  module.exports = function buildLifecycleRouter() {
@@ -0,0 +1,57 @@
1
+ 'use strict';
2
+
3
+ // modules/lifecycle/ship-files.js — the paths a shipped task's pull request
4
+ // changed, from the SERVER's own view of the merge (task 1004201).
5
+ //
6
+ // The artist-review cascade asks "did this ship change a rendered surface?" by
7
+ // the files, because the module_key label it used to key on is usually null.
8
+ // The answer comes from GitHub — the same two reads removal-derivation.js makes,
9
+ // SHARED with it per ship through sharedPrReads — never from a list the building
10
+ // box supplied. A task with no findable pull request changed nothing this can
11
+ // see, and answers no paths.
12
+
13
+ const githubPush = require('./github-push');
14
+
15
+ // github-push.getPullRequestFiles reads at most 3 pages of 100 and says nothing
16
+ // when it stops there, so a list this long may have been cut off. Pinned against
17
+ // that file by test.
18
+ const PR_FILES_CAP = 300;
19
+
20
+ // sharedPrReads — the two GitHub reads behind a ship's pull request, memoised for
21
+ // ONE ship event, so this file and removal-derivation.js ask GitHub once between
22
+ // them. Create one per event; never share it across ships.
23
+ function sharedPrReads(deps = {}) {
24
+ const find = deps.findTaskPullRequest || ((args) => githubPush.findTaskPullRequest(args));
25
+ const list = deps.getPullRequestFiles || ((args) => githubPush.getPullRequestFiles(args));
26
+ const memo = new Map();
27
+ const once = (key, read) => {
28
+ if (!memo.has(key)) memo.set(key, read());
29
+ return memo.get(key);
30
+ };
31
+ return {
32
+ findTaskPullRequest: (args) => once(`pr:${args && args.taskId}`, () => find(args)),
33
+ getPullRequestFiles: (args) => once(`files:${args && args.number}`, () => list(args)),
34
+ };
35
+ }
36
+
37
+ // changedFilesForTask — { paths, truncated } for the task's pull request.
38
+ // `truncated` means the list hit the read cap, so a UI file may sit past it: the
39
+ // review rule files anyway rather than let a padded PR skip it. Throws on a failed
40
+ // read; the cascade runner reports that and falls back to the label match.
41
+ async function changedFilesForTask(task, deps = {}) {
42
+ const findPr = deps.findTaskPullRequest || ((args) => githubPush.findTaskPullRequest(args));
43
+ const getFiles = deps.getPullRequestFiles || ((args) => githubPush.getPullRequestFiles(args));
44
+ const pr = await findPr({ taskId: task && task.id });
45
+ // `pr_number`, NOT `pr.number` — findTaskPullRequest returns { branch, pr_number,
46
+ // pr_state, merged }, and reading the wrong field is silent (removal-derivation.js
47
+ // shipped dead in production over exactly this).
48
+ if (!pr || pr.pr_number == null) return { paths: [], truncated: false };
49
+ const files = await getFiles({ number: Number(pr.pr_number) });
50
+ const rows = Array.isArray(files) ? files : [];
51
+ return {
52
+ paths: rows.map((f) => f && f.path).filter((p) => typeof p === 'string' && p),
53
+ truncated: rows.length >= PR_FILES_CAP,
54
+ };
55
+ }
56
+
57
+ module.exports = { PR_FILES_CAP, sharedPrReads, changedFilesForTask };
@@ -329,5 +329,152 @@ await test("runCascadesForShip honours 'off' end to end — nothing is created,
329
329
  assert.deepEqual(report.errors, [], 'a project that switched the gate off has no problem to report');
330
330
  });
331
331
 
332
+ // --- the review keys on the FILES a ship changed, not its label (task 1004201) --
333
+
334
+ await test('a page file changed under modules/*/public/ is visual work whatever the label says', () => {
335
+ const H = cascade.hasCopyOrVisualWork;
336
+ const unlabelled = shippedCopy({ module_key: null, discipline: 'engineer' });
337
+ assert.equal(H(unlabelled), false, 'the label alone says no — the gap task 1003480 fell through');
338
+ for (const p of [
339
+ 'modules/hall-ui/public/goal-forest-lib.js',
340
+ 'modules/public-landing/public/projects.html',
341
+ 'modules/status-ui/public/status.css',
342
+ 'modules/public-landing/public/assets/world/planet.webp',
343
+ ]) assert.equal(H(unlabelled, ['README.md', p]), true, p);
344
+ assert.equal(H(shippedCopy({ module_key: 'public-landing' })), true, 'public-landing joined the label list');
345
+ });
346
+
347
+ await test('a backend file in a UI module is NOT visual work — page files only', () => {
348
+ const H = cascade.hasCopyOrVisualWork;
349
+ const unlabelled = shippedCopy({ module_key: null, discipline: 'engineer' });
350
+ assert.equal(H(unlabelled, [
351
+ 'modules/hall-ui/module.json',
352
+ 'modules/public-landing/routes/hub.js',
353
+ 'modules/hall-ui/public/goals.states.json',
354
+ 'modules/lifecycle/cascade.js',
355
+ 'public-docs/index.html',
356
+ ]), false);
357
+ assert.equal(H(unlabelled, []), false);
358
+ assert.equal(H(unlabelled, null), false);
359
+ });
360
+
361
+ await test('the ship runner resolves the changed files ONCE and the artist review files on them', async () => {
362
+ const created = [];
363
+ let asked = 0;
364
+ const deps = {
365
+ getTask: async () => shippedCopy({ module_key: null, discipline: 'engineer' }),
366
+ findTaskBySourceRef: async () => null,
367
+ createTask: async (spec) => { created.push(spec); return { id: String(9300 + created.length) }; },
368
+ changedFiles: async () => { asked++; return { paths: ['modules/hall-ui/public/goal-forest-render.js'], truncated: false }; },
369
+ };
370
+ const report = await cascade.runCascadesForShip({ id: '7001' }, deps);
371
+ assert.equal(asked, 1);
372
+ assert.equal(created.filter((s) => s.discipline === 'artist').length, 1, 'the unlabelled UI ship gets its review');
373
+ assert.deepEqual(report.errors, []);
374
+ });
375
+
376
+ await test('a changed-files list cut off at the read cap FILES the review — a padded PR cannot skip it', async () => {
377
+ const run = async (changed) => {
378
+ const created = [];
379
+ await cascade.runCascadesForShip({ id: '7001' }, {
380
+ getTask: async () => shippedCopy({ module_key: null, discipline: 'engineer' }),
381
+ findTaskBySourceRef: async () => null,
382
+ createTask: async (spec) => { created.push(spec); return { id: '9500' }; },
383
+ changedFiles: async () => changed,
384
+ });
385
+ return created.filter((s) => s.discipline === 'artist').length;
386
+ };
387
+ const padding = Array.from({ length: 300 }, (_, i) => `docs/pad-${i}.md`);
388
+ assert.equal(await run({ paths: padding, truncated: true }), 1);
389
+ assert.equal(await run({ paths: padding, truncated: false }), 0, 'the same list, complete, is not UI work');
390
+ });
391
+
392
+ await test('a filed review is one the deploy gate HOLDS on — the rule and the gate agree on its identity', async () => {
393
+ const gate = require_(path.join(HERE, '..', '..', '..', 'src', 'bongos', 'artist-gate.js'));
394
+ const created = [];
395
+ await cascade.runCascadesForShip({ id: '7001' }, {
396
+ getTask: async () => shippedCopy({ module_key: null, discipline: 'engineer' }),
397
+ findTaskBySourceRef: async () => null,
398
+ createTask: async (spec) => { created.push(spec); return { id: '9600' }; },
399
+ changedFiles: async () => ({ paths: ['modules/public-landing/public/projects.html'], truncated: false }),
400
+ });
401
+ const review = created.find((s) => s.discipline === 'artist');
402
+ assert.ok(review, 'the review was filed');
403
+ assert.equal(gate.isArtistReview({ source: review.source, source_ref: review.sourceRef }), true);
404
+ });
405
+
406
+ await test('a changed-files read that fails is REPORTED, never thrown — the label match still stands', async () => {
407
+ const run = async (task) => {
408
+ const created = [];
409
+ const report = await cascade.runCascadesForShip({ id: '7001' }, {
410
+ getTask: async () => task,
411
+ findTaskBySourceRef: async () => null,
412
+ createTask: async (spec) => { created.push(spec); return { id: '9400' }; },
413
+ changedFiles: async () => { throw new Error('github down'); },
414
+ });
415
+ return { created, report };
416
+ };
417
+ const unlabelled = await run(shippedCopy({ module_key: null, discipline: 'engineer' }));
418
+ assert.equal(unlabelled.created.length, 0);
419
+ assert.match(unlabelled.report.errors.join('\n'), /changedFiles\(\) threw \(github down\)/);
420
+ const labelled = await run(shippedCopy({ module_key: 'hall-ui' }));
421
+ assert.equal(labelled.created.filter((s) => s.discipline === 'artist').length, 1, 'an outage never costs a labelled review');
422
+ });
423
+
424
+ const shipFiles = require_(path.join(HERE, '..', 'ship-files.js'));
425
+
426
+ await test('ship-files: reads the merged PR by pr_number and returns its paths', async () => {
427
+ const asked = [];
428
+ const changed = await shipFiles.changedFilesForTask({ id: '7001' }, {
429
+ findTaskPullRequest: async (a) => { asked.push(a); return { branch: 'task-7001', pr_number: 42, merged: true }; },
430
+ getPullRequestFiles: async ({ number }) => (number === 42
431
+ ? [{ path: 'modules/hall-ui/public/a.css', status: 'modified' }, { path: 'modules/hall-ui/public/b.js', status: 'removed', deleted: true }, null]
432
+ : []),
433
+ });
434
+ assert.deepEqual(asked, [{ taskId: '7001' }]);
435
+ assert.deepEqual(changed, { paths: ['modules/hall-ui/public/a.css', 'modules/hall-ui/public/b.js'], truncated: false });
436
+ const noPr = await shipFiles.changedFilesForTask({ id: '7001' }, {
437
+ findTaskPullRequest: async () => ({ number: 42 }), // the WRONG field — must not be read as a PR
438
+ getPullRequestFiles: async () => { throw new Error('must not be called'); },
439
+ });
440
+ assert.deepEqual(noPr, { paths: [], truncated: false });
441
+ });
442
+
443
+ await test('ship-files: a list at the read cap says it may be cut off, and the cap is github-push\'s own', async () => {
444
+ const full = await shipFiles.changedFilesForTask({ id: '1' }, {
445
+ findTaskPullRequest: async () => ({ pr_number: 7 }),
446
+ getPullRequestFiles: async () => Array.from({ length: shipFiles.PR_FILES_CAP }, (_, i) => ({ path: `docs/${i}.md` })),
447
+ });
448
+ assert.equal(full.truncated, true);
449
+ const src = require_('fs').readFileSync(path.join(HERE, '..', 'github-push.js'), 'utf8');
450
+ const body = src.slice(src.indexOf('async function getPullRequestFiles'));
451
+ const pages = Number((body.match(/page <= (\d+)/) || [])[1]);
452
+ const perPage = Number((body.match(/per_page=(\d+)/) || [])[1]);
453
+ assert.equal(pages * perPage, shipFiles.PR_FILES_CAP, 'if getPullRequestFiles reads more, the cap must follow');
454
+ });
455
+
456
+ await test('ship-files: one ship asks GitHub ONCE between the two resolvers that need the PR', async () => {
457
+ let finds = 0;
458
+ let lists = 0;
459
+ const pr = shipFiles.sharedPrReads({
460
+ findTaskPullRequest: async () => { finds++; return { pr_number: 9 }; },
461
+ getPullRequestFiles: async () => { lists++; return [{ path: 'modules/hall-ui/public/x.css' }]; },
462
+ });
463
+ await shipFiles.changedFilesForTask({ id: '5' }, pr);
464
+ await pr.findTaskPullRequest({ taskId: '5' });
465
+ await pr.getPullRequestFiles({ number: 9 });
466
+ assert.equal(finds, 1);
467
+ assert.equal(lists, 1);
468
+ });
469
+
470
+ await test('the ship listener wires the shared reads into BOTH resolvers', () => {
471
+ const fs = require_('fs');
472
+ const src = fs.readFileSync(path.join(HERE, '..', 'routes', 'lifecycle.js'), 'utf8');
473
+ assert.match(src, /require\('\.\.\/ship-files'\)/);
474
+ assert.match(src, /const pr = shipFiles\.sharedPrReads\(\);/, 'made per ship event, inside the listener');
475
+ assert.match(src, /changedFiles:\s*\(task\)\s*=>\s*shipFiles\.changedFilesForTask\(task, pr\)/);
476
+ assert.match(src, /removalDerivation\.removalCandidatesForTask\(task, \{\s*\.\.\.pr,/);
477
+ });
478
+
332
479
  console.log(`\n${passed} passed, ${failed} failed`);
333
480
  process.exit(failed ? 1 : 0);
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.986",
3
+ "version": "1.19.987",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.986",
9
+ "version": "1.19.987",
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.986",
3
+ "version": "1.19.987",
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",
@@ -296,13 +296,13 @@ async function runSubagentGrader({ task, valueSummary, baseline, changedFiles: p
296
296
  // task 1003977: the advisory Reader rides along on copy-bearing diffs only.
297
297
  // Where the predicate says no, `panel` is byte-identical to DEFAULT_PANEL
298
298
  // and the ship costs exactly what it costs today.
299
- const copyBearing = hasCopyOrVisualWork(task);
299
+ const copyBearing = hasCopyOrVisualWork(task, changedFiles);
300
300
  const panel = grader.WORKERS.panelFor({ copyBearing });
301
301
  console.log(` subagent grader ${grader.VERSION}: panel of ${panel.length} workers (${panel.join(', ')}), model=${grader.selectGraderModel({ builderModel })} (builder ${builderModel || `${grader.ASSUMED_BUILDER_MODEL} assumed`}), changed_files=${changedFiles.length}, diff_chars=${diff.length}, per-worker timeout=${GRADER_TIMEOUT_MS}ms`);
302
302
  console.log(` gate: every GATING worker must return verdict='pass'; 'concerns' → manual-confirm fall-through; 'fail' blocks`);
303
303
  console.log(copyBearing
304
304
  ? ` copy lens: ON — this task is copy-bearing, so the advisory 'reader' reads the changed strings against docs/voice.md. It is NON-GATING: nothing it reports, and no error or timeout it hits, can stop this ship.`
305
- : ` copy lens: off — not a copy-bearing task (no copy-proposal source, not artist-discipline, module not in COPY_VISUAL_MODULES), so the panel is the usual four.`);
305
+ : ` copy lens: off — not a copy-bearing task (no copy-proposal source, not artist-discipline, module not in COPY_VISUAL_MODULES, no page file under modules/*/public/ changed), so the panel is the usual four.`);
306
306
  if (priorRound) {
307
307
  console.log(` delta re-grade (task 1002662): prior round's findings + builder response ride along, fenced; answered findings can only re-veto with NEW evidence`);
308
308
  }
package/src/module-api.js CHANGED
@@ -71,7 +71,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
71
71
  // there. scripts/gds/bump-version.js still rewrites the literal below; it appends
72
72
  // the entry to that file. Look for a version's history there, not here.
73
73
  // ---------------------------------------------------------------------------
74
- const CORE_VERSION = '1.19.986'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
74
+ const CORE_VERSION = '1.19.987'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
75
75
 
76
76
  // A namespaced logger so a module's log lines are attributable + consistent.
77
77
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -242,6 +242,13 @@ test('the cascade and the copy lens cover the SAME set by construction', () => {
242
242
  const shipSrc = fs.readFileSync(path.join(ROOT, 'scripts', 'gds', 'ship-grade.js'), 'utf8');
243
243
  assert.match(cascadeSrc, /hasCopyOrVisualWork/);
244
244
  assert.match(shipSrc, /hasCopyOrVisualWork/);
245
+ // Task 1004201: BOTH consumers hand the predicate the files the work changed,
246
+ // so an unlabelled UI ship gets the lens at grade time AND the review on ship.
247
+ assert.match(shipSrc, /hasCopyOrVisualWork\(task, changedFiles\)/, 'the grader passes its own diff');
248
+ assert.match(cascadeSrc, /hasCopyOrVisualWork\(t, \(ctx \|\| \{\}\)\.changedFiles\)/, 'the cascade passes the merged PR');
249
+ const unlabelled = { module_key: null, discipline: 'engineer' };
250
+ assert.equal(hasCopyOrVisualWork(unlabelled, ['modules/hall-ui/public/goal-forest-lib.js']), true);
251
+ assert.equal(hasCopyOrVisualWork(unlabelled, ['modules/hall-ui/module.json', 'scripts/gds/claim.js']), false);
245
252
  });
246
253
 
247
254
  // ---- 8. COST ---------------------------------------------------------------