@bongos/core 1.19.986 → 1.19.988
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 +40 -20
- package/.claude/skills/design/SKILL.md +2 -1
- package/docs/module-api-changelog.md +4 -0
- package/docs/packs/artist.md +1 -0
- package/docs/packs/engineer.md +2 -1
- package/modules/lifecycle/cascade.js +32 -2
- package/modules/lifecycle/routes/lifecycle.js +55 -43
- package/modules/lifecycle/ship-files.js +57 -0
- package/modules/lifecycle/tests/cascade.mjs +147 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/claim.js +7 -0
- package/scripts/gds/review-sheet.js +108 -0
- package/scripts/gds/ship-grade.js +2 -2
- package/scripts/gds/visual-review-nudge.js +37 -0
- package/src/module-api.js +1 -1
- package/tests/grader_reader_lens.mjs +7 -0
- package/tests/visual_review.mjs +110 -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.988",
|
|
6
|
+
"core_contract": "1.19.988",
|
|
7
|
+
"source_commit": "fe4b08f918073241c24208ac22bb4e2a505a00f4",
|
|
8
8
|
"source_ref": "HEAD",
|
|
9
|
-
"built_at": "2026-09-
|
|
9
|
+
"built_at": "2026-09-23T19:26:45.671Z",
|
|
10
10
|
"redaction": {
|
|
11
11
|
"model": "docs-redacted+functional-verbatim",
|
|
12
12
|
"docs_redacted": 518,
|
|
13
13
|
"agent_docs_stubbed": 26,
|
|
14
|
-
"functional_verbatim":
|
|
14
|
+
"functional_verbatim": 2394,
|
|
15
15
|
"rules": 3,
|
|
16
16
|
"gate_literals": 3,
|
|
17
17
|
"gate": "passed"
|
|
18
18
|
},
|
|
19
|
-
"file_count":
|
|
20
|
-
"tree_sha256": "
|
|
19
|
+
"file_count": 2938,
|
|
20
|
+
"tree_sha256": "bc9a86c3157f1080179c3d82293d7cfad4a40cc7b5895538465816e8f1238416",
|
|
21
21
|
"files": [
|
|
22
22
|
{
|
|
23
23
|
"path": ".claude/skills/ask-for-help/SKILL.md",
|
|
@@ -127,7 +127,7 @@
|
|
|
127
127
|
{
|
|
128
128
|
"path": ".claude/skills/design/SKILL.md",
|
|
129
129
|
"mode": "0000644",
|
|
130
|
-
"sha256": "
|
|
130
|
+
"sha256": "2ffaa5f9c6b6ff5b9a3a4730b4d172cdb905f1ed5efa879d3b14dca5ae60aeeb"
|
|
131
131
|
},
|
|
132
132
|
{
|
|
133
133
|
"path": ".claude/skills/feedback/SKILL.md",
|
|
@@ -2712,7 +2712,7 @@
|
|
|
2712
2712
|
{
|
|
2713
2713
|
"path": "docs/module-api-changelog.md",
|
|
2714
2714
|
"mode": "0000644",
|
|
2715
|
-
"sha256": "
|
|
2715
|
+
"sha256": "22b37ede3331f4be0569380c58ab10e90d79aaaf3dd52191d6fc49bab095b2b2"
|
|
2716
2716
|
},
|
|
2717
2717
|
{
|
|
2718
2718
|
"path": "docs/modules-contract.md",
|
|
@@ -2787,12 +2787,12 @@
|
|
|
2787
2787
|
{
|
|
2788
2788
|
"path": "docs/packs/artist.md",
|
|
2789
2789
|
"mode": "0000644",
|
|
2790
|
-
"sha256": "
|
|
2790
|
+
"sha256": "ef94247b390fe32676c910545c35a7cfd39bb2ed87680174d7bd590eb1fbe2af"
|
|
2791
2791
|
},
|
|
2792
2792
|
{
|
|
2793
2793
|
"path": "docs/packs/engineer.md",
|
|
2794
2794
|
"mode": "0000644",
|
|
2795
|
-
"sha256": "
|
|
2795
|
+
"sha256": "76c058f9fa63846caf132e2b4703ae7913572d59dbcb47f3e8b565f21262fea8"
|
|
2796
2796
|
},
|
|
2797
2797
|
{
|
|
2798
2798
|
"path": "docs/packs/ideator.md",
|
|
@@ -5707,7 +5707,7 @@
|
|
|
5707
5707
|
{
|
|
5708
5708
|
"path": "modules/lifecycle/cascade.js",
|
|
5709
5709
|
"mode": "0000644",
|
|
5710
|
-
"sha256": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
8165
|
+
"sha256": "b852a0142a8be848b6e231b88b0c7b5e2f86baa0b88dcb44e42c1f1e65afb9ea"
|
|
8161
8166
|
},
|
|
8162
8167
|
{
|
|
8163
8168
|
"path": "package.json",
|
|
8164
8169
|
"mode": "0000644",
|
|
8165
|
-
"sha256": "
|
|
8170
|
+
"sha256": "46df90fa4ee1a722331e5bd7a6531f82e4245a545412e70ef73825bce85840e7"
|
|
8166
8171
|
},
|
|
8167
8172
|
{
|
|
8168
8173
|
"path": "public-docs/index.html",
|
|
@@ -8497,7 +8502,7 @@
|
|
|
8497
8502
|
{
|
|
8498
8503
|
"path": "scripts/gds/claim.js",
|
|
8499
8504
|
"mode": "0000644",
|
|
8500
|
-
"sha256": "
|
|
8505
|
+
"sha256": "c9a7f39cf1f03e985466005a1857f570b4b3d449b0cbd8b3dfd0f7a7524a7c31"
|
|
8501
8506
|
},
|
|
8502
8507
|
{
|
|
8503
8508
|
"path": "scripts/gds/classify-discipline.js",
|
|
@@ -9254,6 +9259,11 @@
|
|
|
9254
9259
|
"mode": "0000644",
|
|
9255
9260
|
"sha256": "084fa5c4da1e755fa0edde5ec770e5151a9f66a247bca222bc7a2366ccf61748"
|
|
9256
9261
|
},
|
|
9262
|
+
{
|
|
9263
|
+
"path": "scripts/gds/review-sheet.js",
|
|
9264
|
+
"mode": "0000644",
|
|
9265
|
+
"sha256": "a0234b79890f9eae0fdc9d1910b723d61d21ca6b2888092b45084fb13c0c63c6"
|
|
9266
|
+
},
|
|
9257
9267
|
{
|
|
9258
9268
|
"path": "scripts/gds/role-pack-guard.js",
|
|
9259
9269
|
"mode": "0000644",
|
|
@@ -9532,7 +9542,7 @@
|
|
|
9532
9542
|
{
|
|
9533
9543
|
"path": "scripts/gds/ship-grade.js",
|
|
9534
9544
|
"mode": "0000644",
|
|
9535
|
-
"sha256": "
|
|
9545
|
+
"sha256": "eb0740f3cdc3dc91038320dd9044dc81c05e31212059d772d5c8bf6e9f44b474"
|
|
9536
9546
|
},
|
|
9537
9547
|
{
|
|
9538
9548
|
"path": "scripts/gds/ship-honesty.js",
|
|
@@ -9754,6 +9764,11 @@
|
|
|
9754
9764
|
"mode": "0000644",
|
|
9755
9765
|
"sha256": "be4fec619d5a0c76ba05bcc75042c1497f70da21b54a3d646884e2228f90b2ba"
|
|
9756
9766
|
},
|
|
9767
|
+
{
|
|
9768
|
+
"path": "scripts/gds/visual-review-nudge.js",
|
|
9769
|
+
"mode": "0000644",
|
|
9770
|
+
"sha256": "a86b2cd427c9b52090d906b0b5febb9d8098fae1589e01828e4d7698d38db599"
|
|
9771
|
+
},
|
|
9757
9772
|
{
|
|
9758
9773
|
"path": "scripts/gds/worktree-claim-guard.js",
|
|
9759
9774
|
"mode": "0000644",
|
|
@@ -10187,7 +10202,7 @@
|
|
|
10187
10202
|
{
|
|
10188
10203
|
"path": "src/module-api.js",
|
|
10189
10204
|
"mode": "0000644",
|
|
10190
|
-
"sha256": "
|
|
10205
|
+
"sha256": "2a9b6a4c34d0321b9684a3054d93a5224359fcdc89d81df9ec01bee4334c25c7"
|
|
10191
10206
|
},
|
|
10192
10207
|
{
|
|
10193
10208
|
"path": "src/module-loader/catalog.js",
|
|
@@ -12037,7 +12052,7 @@
|
|
|
12037
12052
|
{
|
|
12038
12053
|
"path": "tests/grader_reader_lens.mjs",
|
|
12039
12054
|
"mode": "0000644",
|
|
12040
|
-
"sha256": "
|
|
12055
|
+
"sha256": "cacfafb2e8408bfcc2137ab1bf11bed21cf95c720aa0a7bc5cdbf0386f4833c8"
|
|
12041
12056
|
},
|
|
12042
12057
|
{
|
|
12043
12058
|
"path": "tests/grader_regrade_delta.mjs",
|
|
@@ -14609,6 +14624,11 @@
|
|
|
14609
14624
|
"mode": "0000644",
|
|
14610
14625
|
"sha256": "8a8df7e2a025029ffe7e18b7c7b18c6bd92a256822609931f0cd88628e446041"
|
|
14611
14626
|
},
|
|
14627
|
+
{
|
|
14628
|
+
"path": "tests/visual_review.mjs",
|
|
14629
|
+
"mode": "0000644",
|
|
14630
|
+
"sha256": "6c583bd0cb3c2611044e5adad4c17a57d5aed89ba5daf921c355903df239a2ab"
|
|
14631
|
+
},
|
|
14612
14632
|
{
|
|
14613
14633
|
"path": "tests/wandering_prefs.mjs",
|
|
14614
14634
|
"mode": "0000644",
|
|
@@ -49,7 +49,8 @@ Generated code uses the `--dt-*` variables; adapters write only `src/ui/<surface
|
|
|
49
49
|
- `node scripts/gds/validate-design.js --strict` after touching adapters, tokens or `design-sources.json` (fitness runs it on every PR).
|
|
50
50
|
- **New interactive code gets a pin in the page's own test file in the same diff** — the grader's Quality worker fails a chrome change that arrives with no test. Contrast pairs are measured in CI, not eyeballed.
|
|
51
51
|
- The task's DONE WHEN is read literally — "both pages" means both pages. Deliberate deviations from a mock stay minor if the ship notes say why.
|
|
52
|
-
-
|
|
52
|
+
- **The person you're working with OKs it before it ships** — the project default for every builder (owner, 2026-09-23). Open the before/after shots in their browser with `node scripts/gds/review-sheet.js --title … --shot "<caption>=<png>"` (a terminal shows no images, so a screenshot you only read yourself never reached them), and wait. Autonomous: no one to ask, so list the shots in the notes and ship; the change files a visual review that holds the deploy (ADR 0241), never the ship.
|
|
53
|
+
- `/builder-ship` with real `--notes` (what changed, which screens you looked at, in which modes, and who OKed them) and a plain-language `--summary`.
|
|
53
54
|
|
|
54
55
|
## Round-tripping with a design tool
|
|
55
56
|
|
|
@@ -2446,5 +2446,9 @@ 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)
|
|
2451
|
+
1.19.988 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
2452
|
+
landed since 1.19.987 with no explicit bump. run 35909292753. (task 1002620)
|
|
2449
2453
|
---------------------------------------------------------------------------
|
|
2450
2454
|
```
|
package/docs/packs/artist.md
CHANGED
|
@@ -15,6 +15,7 @@ In every other craft Claude narrates — diffs, logs, paragraphs about what it i
|
|
|
15
15
|
- **End every step with something visual.** After a generation, display the image. After a copy change, show the rendered text in place, not a diff. After a check, show the scorecard, not a prose summary. They should see the result before they read a word.
|
|
16
16
|
- **Collapse the plumbing to one-liners.** Budget checks, config edits, retry tiers, atlas rebuilds — do them, report them as a single line or a number. "Generated, scored 0.91, on-palette, staged ✓" beats three paragraphs.
|
|
17
17
|
- **Verification is looking, not reading.** Surface the rendered surface and the sandbox preview, not console logs. Logs are for when something breaks, not for routine success.
|
|
18
|
+
- **Nothing that changes a page ships until they have seen it** (the project default, owner 2026-09-23). Show the before and after in both modes at desktop and phone width, opened in their browser with `node scripts/gds/review-sheet.js` (a terminal shows no images), and ship on their OK.
|
|
18
19
|
|
|
19
20
|
## The second rule: as little interpretation as possible
|
|
20
21
|
|
package/docs/packs/engineer.md
CHANGED
|
@@ -20,7 +20,8 @@ Engineering is the project's **default loop**, so this pack introduces no new dy
|
|
|
20
20
|
|
|
21
21
|
4. **Verify it — never report done on faith.** The step engineers most often skip.
|
|
22
22
|
- Run the smoke tests yourself while iterating (`/builder-ship` runs them too, but later is not the same as sooner).
|
|
23
|
-
- If the change is observable in the running app, use the preview/verification workflow (preview → reload → console + page check → screenshot proof)
|
|
23
|
+
- If the change is observable in the running app, use the preview/verification workflow (preview → reload → console + page check → screenshot proof) yourself first. Verify, then share the proof.
|
|
24
|
+
- **A change to a page people see is reviewed by your human before it ships — the project default for every builder (owner, 2026-09-23).** Render it before and after, in both modes, at desktop and phone width; open the shots for them with `node scripts/gds/review-sheet.js` (a terminal shows no images — this opens one page in their browser); wait for their OK, then ship. With nobody to ask, list the shots in the ship notes and ship: the change also files a visual review on the board that holds the deploy (ADR 0241), never the ship.
|
|
24
25
|
- If it is not browser-observable (a different runtime, types, tooling), say so and verify the way that *does* exercise it.
|
|
25
26
|
- `/ship-check` runs every freshness and fitness gate `ship.js` will run, and prints the healing command for each stale generated file.
|
|
26
27
|
|
|
@@ -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
|
-
|
|
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
|
|
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) =>
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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.
|
|
3
|
+
"version": "1.19.988",
|
|
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.988",
|
|
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.988",
|
|
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/scripts/gds/claim.js
CHANGED
|
@@ -36,6 +36,7 @@ const { plainCardLines } = require('./plain-cards');
|
|
|
36
36
|
// and the detector. A lifecycle CLI may require the lifecycle module directly
|
|
37
37
|
// (the documented exception in modules/lifecycle/CLAUDE.md).
|
|
38
38
|
const { splitDeadDeps, deadDepHint } = require('../../modules/lifecycle/dead-deps.js');
|
|
39
|
+
const { visualReviewNudge } = require('./visual-review-nudge');
|
|
39
40
|
|
|
40
41
|
// Fetch up to 3 learnings relevant to the task being claimed. touches[] cleanup
|
|
41
42
|
// [3/8] (task 878): key off the task's discipline + title (always present) rather
|
|
@@ -728,6 +729,12 @@ async function main() {
|
|
|
728
729
|
// playbook) into its skill. Printed right after the description so it's the
|
|
729
730
|
// last thing the builder reads before deciding how to work — task 1267.
|
|
730
731
|
printDisciplineMode(task);
|
|
732
|
+
// task 1004202: a task that changes a page is told to use /design and get its
|
|
733
|
+
// human's OK on the before/after. A nudge, never a gate (ADR 0162).
|
|
734
|
+
try {
|
|
735
|
+
const nudge = visualReviewNudge(task);
|
|
736
|
+
if (nudge) console.log(`\n${nudge.join('\n')}`);
|
|
737
|
+
} catch (_) { /* never let a hint break a successful claim */ }
|
|
731
738
|
if (worktree) console.log(`\nWorktree: ${worktree}`);
|
|
732
739
|
// Claim-time knowledge (#956 / V4.R18, criterion sessions-start-knowing):
|
|
733
740
|
// learnings first (curated, short), then the top recall sources for this task
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
// scripts/gds/review-sheet.js — open before/after screenshots for a person to
|
|
5
|
+
// review, as ONE page in their own browser (task 1004202).
|
|
6
|
+
//
|
|
7
|
+
// WHY. Every change to a rendered surface is reviewed by the session's human
|
|
8
|
+
// before it ships — the project default since the owner's 2026-09-23 decision.
|
|
9
|
+
// A terminal shows no images: a screenshot an agent "sends" or reads into its
|
|
10
|
+
// own context never reaches a CLI user's eyes. So the review is a local HTML page
|
|
11
|
+
// of captioned shots, opened with the OS's own opener. Nothing is uploaded.
|
|
12
|
+
//
|
|
13
|
+
// Usage:
|
|
14
|
+
// node scripts/gds/review-sheet.js --title "Task 1004200 — graph titles" \
|
|
15
|
+
// --shot "Before — 1280, light=C:/shots/before.png" \
|
|
16
|
+
// --shot "After — 1280, light=C:/shots/after.png" \
|
|
17
|
+
// [--note "One line on what changed."] [--out <dir>] [--no-open]
|
|
18
|
+
//
|
|
19
|
+
// Prints the page's path. Each --shot is "<caption>=<image path>", in order.
|
|
20
|
+
|
|
21
|
+
const fs = require('node:fs');
|
|
22
|
+
const os = require('node:os');
|
|
23
|
+
const path = require('node:path');
|
|
24
|
+
const { pathToFileURL } = require('node:url');
|
|
25
|
+
const { spawn } = require('node:child_process');
|
|
26
|
+
|
|
27
|
+
function escapeHtml(s) {
|
|
28
|
+
return String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// parseArgs — the flags above; `shots` keep their order. A --shot with no "="
|
|
32
|
+
// is an error, not a guess: the caption and the path cannot be told apart.
|
|
33
|
+
function parseArgs(argv) {
|
|
34
|
+
const out = { title: 'Visual review', note: '', shots: [], outDir: null, open: true, errors: [] };
|
|
35
|
+
for (let i = 0; i < argv.length; i++) {
|
|
36
|
+
const a = argv[i];
|
|
37
|
+
if (a === '--no-open') { out.open = false; continue; }
|
|
38
|
+
const v = argv[i + 1];
|
|
39
|
+
if (a === '--title') { out.title = v; i++; }
|
|
40
|
+
else if (a === '--note') { out.note = v; i++; }
|
|
41
|
+
else if (a === '--out') { out.outDir = v; i++; }
|
|
42
|
+
else if (a === '--shot') {
|
|
43
|
+
i++;
|
|
44
|
+
const at = String(v || '').lastIndexOf('=');
|
|
45
|
+
if (at < 1) out.errors.push(`--shot needs "<caption>=<image path>", got: ${v}`);
|
|
46
|
+
else out.shots.push({ caption: v.slice(0, at).trim(), file: v.slice(at + 1).trim() });
|
|
47
|
+
} else out.errors.push(`unknown argument: ${a}`);
|
|
48
|
+
}
|
|
49
|
+
if (!out.shots.length) out.errors.push('at least one --shot is required');
|
|
50
|
+
return out;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// buildReviewPage — the page itself. Images are absolute file:// URLs, so the
|
|
54
|
+
// page works wherever it is written and nothing is copied.
|
|
55
|
+
function buildReviewPage({ title, note, shots }) {
|
|
56
|
+
const figures = shots.map((s) => `<figure><figcaption>${escapeHtml(s.caption)}</figcaption>`
|
|
57
|
+
+ `<img src="${escapeHtml(pathToFileURL(path.resolve(s.file)).href)}" alt="${escapeHtml(s.caption)}"></figure>`).join('\n');
|
|
58
|
+
return `<!doctype html>
|
|
59
|
+
<html lang="en">
|
|
60
|
+
<head>
|
|
61
|
+
<meta charset="utf-8">
|
|
62
|
+
<title>${escapeHtml(title)}</title>
|
|
63
|
+
<style>
|
|
64
|
+
body { margin: 0; padding: 24px; font: 15px/1.5 system-ui, sans-serif; background: #f4f4f5; color: #17181a; }
|
|
65
|
+
h1 { font-size: 20px; margin: 0 0 6px; }
|
|
66
|
+
p { margin: 0 0 20px; max-width: 70ch; }
|
|
67
|
+
figure { margin: 0 0 28px; }
|
|
68
|
+
figcaption { font-weight: 600; margin-bottom: 6px; }
|
|
69
|
+
img { display: block; max-width: 100%; border: 1px solid #d4d4d8; border-radius: 8px; }
|
|
70
|
+
</style>
|
|
71
|
+
</head>
|
|
72
|
+
<body>
|
|
73
|
+
<h1>${escapeHtml(title)}</h1>
|
|
74
|
+
${note ? `<p>${escapeHtml(note)}</p>\n` : ''}${figures}
|
|
75
|
+
</body>
|
|
76
|
+
</html>
|
|
77
|
+
`;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function openInBrowser(file) {
|
|
81
|
+
// `start`'s first quoted argument is a window title, hence the empty "".
|
|
82
|
+
const [cmd, args] = process.platform === 'win32' ? ['cmd', ['/c', 'start', '""', `"${file}"`]]
|
|
83
|
+
: process.platform === 'darwin' ? ['open', [file]] : ['xdg-open', [file]];
|
|
84
|
+
const child = spawn(cmd, args, { detached: true, stdio: 'ignore', windowsVerbatimArguments: process.platform === 'win32' });
|
|
85
|
+
child.on('error', () => console.error(`Could not open a browser — open this file yourself: ${file}`));
|
|
86
|
+
child.unref();
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function main(argv) {
|
|
90
|
+
const opts = parseArgs(argv);
|
|
91
|
+
const missing = opts.shots.filter((s) => !fs.existsSync(s.file)).map((s) => `no such image: ${s.file}`);
|
|
92
|
+
const errors = [...opts.errors, ...missing];
|
|
93
|
+
if (errors.length) {
|
|
94
|
+
console.error(errors.join('\n'));
|
|
95
|
+
return 1;
|
|
96
|
+
}
|
|
97
|
+
const dir = opts.outDir || fs.mkdtempSync(path.join(os.tmpdir(), 'bongos-review-'));
|
|
98
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
99
|
+
const file = path.join(dir, 'index.html');
|
|
100
|
+
fs.writeFileSync(file, buildReviewPage(opts));
|
|
101
|
+
console.log(file);
|
|
102
|
+
if (opts.open) openInBrowser(file);
|
|
103
|
+
return 0;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (require.main === module) process.exitCode = main(process.argv.slice(2));
|
|
107
|
+
|
|
108
|
+
module.exports = { parseArgs, buildReviewPage };
|
|
@@ -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
|
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// scripts/gds/visual-review-nudge.js — the claim-time lines that tell a session
|
|
4
|
+
// its task changes a page people see (task 1004202): use /design, check it in a
|
|
5
|
+
// real browser, and get the human's OK on the before/after before shipping — the
|
|
6
|
+
// project default for every builder since the owner's 2026-09-23 decision.
|
|
7
|
+
//
|
|
8
|
+
// A NUDGE, never a gate (ADR 0162). "Is this UI work" is the ONE predicate the
|
|
9
|
+
// artist review and the grader's copy lens already use (lifecycle/cascade.js), so
|
|
10
|
+
// the three cannot disagree. Nothing has changed yet at claim time, so it is fed
|
|
11
|
+
// the paths the task NAMES: its touches[] and any modules/*/public/ path written
|
|
12
|
+
// in its description.
|
|
13
|
+
|
|
14
|
+
const { hasCopyOrVisualWork } = require('../../modules/lifecycle/cascade.js');
|
|
15
|
+
|
|
16
|
+
const PUBLIC_PATH_IN_TEXT = /modules\/[\w.-]+\/public\/[^\s`'"()<>[\]{}|,;]+/g;
|
|
17
|
+
|
|
18
|
+
// pathsNamedIn — touches[] plus every public/ path the description mentions,
|
|
19
|
+
// with sentence punctuation stripped off the end ("…/goal-forest-lib.js." → .js).
|
|
20
|
+
function pathsNamedIn(task) {
|
|
21
|
+
const touches = Array.isArray(task && task.touches) ? task.touches.filter((t) => typeof t === 'string') : [];
|
|
22
|
+
const named = (String((task && task.description) || '').match(PUBLIC_PATH_IN_TEXT) || [])
|
|
23
|
+
.map((p) => p.replace(/[.:]+$/, ''));
|
|
24
|
+
return [...touches, ...named];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// visualReviewNudge — the lines to print, or null when the task is not UI work.
|
|
28
|
+
function visualReviewNudge(task) {
|
|
29
|
+
if (!hasCopyOrVisualWork(task, pathsNamedIn(task))) return null;
|
|
30
|
+
return [
|
|
31
|
+
'🎨 This task changes a page people see.',
|
|
32
|
+
' Build it with /design, and check it in a real browser at desktop and phone width, light and dark.',
|
|
33
|
+
' Before you ship: open the before/after for your human (node scripts/gds/review-sheet.js) and wait for their OK.',
|
|
34
|
+
];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
module.exports = { pathsNamedIn, visualReviewNudge };
|
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.
|
|
74
|
+
const CORE_VERSION = '1.19.988'; // 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 ---------------------------------------------------------------
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// tests/visual_review.mjs — the visual review as the project default for every
|
|
2
|
+
// builder (task 1004202; owner decision 2026-09-23).
|
|
3
|
+
//
|
|
4
|
+
// Three parts, pinned together because each is useless without the others: the
|
|
5
|
+
// review page a CLI user can actually SEE (review-sheet.js), the claim-time nudge
|
|
6
|
+
// that tells a UI task to use it (visual-review-nudge.js + claim.js), and the
|
|
7
|
+
// three playbooks every builder's session reads, which carry the rule itself.
|
|
8
|
+
//
|
|
9
|
+
// Run: node tests/visual_review.mjs
|
|
10
|
+
import assert from 'node:assert/strict';
|
|
11
|
+
import fs from 'node:fs';
|
|
12
|
+
import os from 'node:os';
|
|
13
|
+
import path from 'node:path';
|
|
14
|
+
import { spawnSync } from 'node:child_process';
|
|
15
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
16
|
+
import { makeRunner } from './helpers.mjs';
|
|
17
|
+
import { parseArgs, buildReviewPage } from '../scripts/gds/review-sheet.js';
|
|
18
|
+
import { pathsNamedIn, visualReviewNudge } from '../scripts/gds/visual-review-nudge.js';
|
|
19
|
+
|
|
20
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
21
|
+
const read = (rel) => fs.readFileSync(path.join(ROOT, rel), 'utf8');
|
|
22
|
+
const { test, summary } = makeRunner();
|
|
23
|
+
|
|
24
|
+
// ---- the review page ---------------------------------------------------------
|
|
25
|
+
|
|
26
|
+
await test('parseArgs keeps the shots in order and splits caption from path at the LAST "="', () => {
|
|
27
|
+
const o = parseArgs(['--title', 'T', '--shot', 'Before — a=b=C:/x/one.png', '--shot', 'After=C:/x/two.png', '--no-open']);
|
|
28
|
+
assert.deepEqual(o.shots, [{ caption: 'Before — a=b', file: 'C:/x/one.png' }, { caption: 'After', file: 'C:/x/two.png' }]);
|
|
29
|
+
assert.equal(o.open, false);
|
|
30
|
+
assert.deepEqual(o.errors, []);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
await test('parseArgs refuses a shot it cannot split, and a page with no shots', () => {
|
|
34
|
+
assert.match(parseArgs(['--shot', 'nocaption']).errors.join('\n'), /needs "<caption>=<image path>"/);
|
|
35
|
+
assert.match(parseArgs([]).errors.join('\n'), /at least one --shot/);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
await test('the page shows every shot as a captioned image, and no caption becomes markup', () => {
|
|
39
|
+
const html = buildReviewPage({
|
|
40
|
+
title: 'Task <b>1</b>',
|
|
41
|
+
note: 'A & B',
|
|
42
|
+
shots: [{ caption: 'Before <img onerror=x>', file: 'C:/shots/before.png' }, { caption: 'After', file: 'C:/shots/after.png' }],
|
|
43
|
+
});
|
|
44
|
+
assert.ok(!html.includes('<b>1</b>') && !html.includes('<img onerror'), 'captions and title are escaped');
|
|
45
|
+
assert.ok(html.includes('A & B'));
|
|
46
|
+
const srcs = [...html.matchAll(/<img src="([^"]+)"/g)].map((m) => m[1]);
|
|
47
|
+
assert.deepEqual(srcs, [pathToFileURL(path.resolve('C:/shots/before.png')).href, pathToFileURL(path.resolve('C:/shots/after.png')).href]);
|
|
48
|
+
assert.ok(html.indexOf('Before') < html.indexOf('After'), 'in the order given');
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
await test('the CLI writes the page and prints its path; a missing image is an error, not a blank page', () => {
|
|
52
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'visual-review-test-'));
|
|
53
|
+
const png = path.join(dir, 'shot.png');
|
|
54
|
+
fs.writeFileSync(png, 'not really a png');
|
|
55
|
+
const cli = path.join(ROOT, 'scripts', 'gds', 'review-sheet.js');
|
|
56
|
+
const ok = spawnSync(process.execPath, [cli, '--shot', `Before=${png}`, '--out', dir, '--no-open'], { encoding: 'utf8' });
|
|
57
|
+
assert.equal(ok.status, 0, ok.stderr);
|
|
58
|
+
assert.equal(ok.stdout.trim(), path.join(dir, 'index.html'));
|
|
59
|
+
assert.match(fs.readFileSync(path.join(dir, 'index.html'), 'utf8'), /<figcaption>Before<\/figcaption>/);
|
|
60
|
+
const bad = spawnSync(process.execPath, [cli, '--shot', `After=${path.join(dir, 'nope.png')}`, '--no-open'], { encoding: 'utf8' });
|
|
61
|
+
assert.equal(bad.status, 1);
|
|
62
|
+
assert.match(bad.stderr, /no such image/);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
// ---- the claim-time nudge ----------------------------------------------------
|
|
66
|
+
|
|
67
|
+
const task = (over = {}) => ({ id: '1', discipline: 'engineer', module_key: null, source: 'manual', touches: [], description: '', ...over });
|
|
68
|
+
|
|
69
|
+
await test('a task that names a page file in its description is told to use /design and get an OK', () => {
|
|
70
|
+
const t = task({ description: 'Where: modules/hall-ui/public/goal-forest-lib.js — NODE_W = 168. Drawing is modules/hall-ui/public/goal-forest-render.js.' });
|
|
71
|
+
assert.deepEqual(pathsNamedIn(t), ['modules/hall-ui/public/goal-forest-lib.js', 'modules/hall-ui/public/goal-forest-render.js'],
|
|
72
|
+
'sentence punctuation is not part of a path');
|
|
73
|
+
const lines = visualReviewNudge(t);
|
|
74
|
+
assert.ok(lines, 'nudged');
|
|
75
|
+
assert.match(lines.join('\n'), /\/design/);
|
|
76
|
+
assert.match(lines.join('\n'), /review-sheet\.js/);
|
|
77
|
+
assert.match(lines.join('\n'), /OK/);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
await test('touches[], a UI module label, and the artist craft nudge too — the same predicate the review uses', () => {
|
|
81
|
+
assert.ok(visualReviewNudge(task({ touches: ['modules/public-landing/public/projects.html'] })));
|
|
82
|
+
assert.ok(visualReviewNudge(task({ module_key: 'hall-ui' })));
|
|
83
|
+
assert.ok(visualReviewNudge(task({ discipline: 'artist' })));
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
await test('a backend task is not nudged — no noise on work that renders nothing', () => {
|
|
87
|
+
assert.equal(visualReviewNudge(task({ description: 'Fix modules/lifecycle/cascade.js and modules/hall-ui/module.json.' })), null);
|
|
88
|
+
assert.equal(visualReviewNudge(task({ touches: ['scripts/gds/claim.js'] })), null);
|
|
89
|
+
assert.equal(visualReviewNudge(task()), null);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
await test('claim.js prints the nudge after the craft directive, and cannot fail a claim with it', () => {
|
|
93
|
+
const src = read('scripts/gds/claim.js');
|
|
94
|
+
const at = src.indexOf('visualReviewNudge(task)');
|
|
95
|
+
assert.ok(at > src.indexOf('printDisciplineMode(task);'), 'right after the craft directive');
|
|
96
|
+
assert.match(src.slice(at - 200, at + 300), /catch \(_\)/, 'wrapped, so a hint can never break a successful claim');
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
// ---- the rule, where every builder's session reads it ------------------------
|
|
100
|
+
|
|
101
|
+
await test('all three playbooks carry the rule and the way to show a CLI user the shots', () => {
|
|
102
|
+
for (const rel of ['docs/packs/engineer.md', 'docs/packs/artist.md', '.claude/skills/design/SKILL.md']) {
|
|
103
|
+
const doc = read(rel);
|
|
104
|
+
assert.match(doc, /review-sheet\.js/, `${rel} names the helper`);
|
|
105
|
+
assert.match(doc, /2026-09-23/, `${rel} dates the owner decision`);
|
|
106
|
+
}
|
|
107
|
+
assert.doesNotMatch(read('docs/packs/engineer.md'), /rather than asking a human to look/, 'the old contrary instruction is gone');
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
summary();
|