@bongos/core 1.19.987 → 1.19.989

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.987",
6
- "core_contract": "1.19.987",
7
- "source_commit": "102ad9a7c1c780717451cc48241d577442dc44f1",
5
+ "core_version": "1.19.989",
6
+ "core_contract": "1.19.989",
7
+ "source_commit": "e2ff7b827391116e3e99f4b850441ca7482a3466",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-09-23T19:04:45.567Z",
9
+ "built_at": "2026-09-23T19:44:16.401Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
12
  "docs_redacted": 518,
13
13
  "agent_docs_stubbed": 26,
14
- "functional_verbatim": 2391,
14
+ "functional_verbatim": 2394,
15
15
  "rules": 3,
16
16
  "gate_literals": 3,
17
17
  "gate": "passed"
18
18
  },
19
- "file_count": 2935,
20
- "tree_sha256": "e0b9f64be067a45f8a343766f231127d7bb3708dc50656f188e122f06b15ac2a",
19
+ "file_count": 2938,
20
+ "tree_sha256": "65dea20f4453b26ed36b673a7a34da0008c338d0f4307aa1b327f3171ce42cb0",
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": "1ead0250cf028382a55dfd110faa2f787ea8fe14a7927b2272f6891bde30fc2e"
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": "fcad58b07e48eb75f7dbfb280b1d2f167d07a753388aa063d292c7aee323accf"
2715
+ "sha256": "b06d9ffcfde6e55a0997ffa926a671d0f53478d5e7c351b92374485e359cc0cd"
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": "f5754df3bd633f317b9b221640b519ecdc1fdbb68d9ca322329cc92b365c9524"
2790
+ "sha256": "ef94247b390fe32676c910545c35a7cfd39bb2ed87680174d7bd590eb1fbe2af"
2791
2791
  },
2792
2792
  {
2793
2793
  "path": "docs/packs/engineer.md",
2794
2794
  "mode": "0000644",
2795
- "sha256": "b7cb50de31efaa49ce41a475a916abc652454a2eacca5aaaaf8ee8cba46067c6"
2795
+ "sha256": "76c058f9fa63846caf132e2b4703ae7913572d59dbcb47f3e8b565f21262fea8"
2796
2796
  },
2797
2797
  {
2798
2798
  "path": "docs/packs/ideator.md",
@@ -5407,7 +5407,7 @@
5407
5407
  {
5408
5408
  "path": "modules/hall-ui/public/style.css",
5409
5409
  "mode": "0000644",
5410
- "sha256": "1ec46daca175eab41afa686c27c66749b46d0765baa099c36328ffd8706c8c7f"
5410
+ "sha256": "b8a9b64e1febe57eddbb1005335712a19e581e69d16bb42346eba24bd73acfb7"
5411
5411
  },
5412
5412
  {
5413
5413
  "path": "modules/hall-ui/public/task-detail.js",
@@ -5492,7 +5492,7 @@
5492
5492
  {
5493
5493
  "path": "modules/hall-ui/public/work.css",
5494
5494
  "mode": "0000644",
5495
- "sha256": "275d7dac3b28713810a3bc615a8820fdc66b8bee8e0f919c00a3ea7908a786c5"
5495
+ "sha256": "68399594a00fdffaf9833d3105d8c9e2f5872642b0f49868edae6cc526fa9021"
5496
5496
  },
5497
5497
  {
5498
5498
  "path": "modules/hall-ui/public/work.html",
@@ -8162,12 +8162,12 @@
8162
8162
  {
8163
8163
  "path": "package-lock.json",
8164
8164
  "mode": "0000644",
8165
- "sha256": "80f51079ed98cab29d9b48f5f20049f58104b76d68645ac01d03a7bdcde9f29e"
8165
+ "sha256": "09936259916688c880c3c787419714a83a798ccf7fd09b50d7fe26d3d293e600"
8166
8166
  },
8167
8167
  {
8168
8168
  "path": "package.json",
8169
8169
  "mode": "0000644",
8170
- "sha256": "fe62f7efb8e83d82f66dd2277b8ae4da8d448a9286727ea88460ec358f339fc5"
8170
+ "sha256": "126708cbc785a14f0cf42ef0a151889033a852f1a331e6ab73b0c75743f41856"
8171
8171
  },
8172
8172
  {
8173
8173
  "path": "public-docs/index.html",
@@ -8502,7 +8502,7 @@
8502
8502
  {
8503
8503
  "path": "scripts/gds/claim.js",
8504
8504
  "mode": "0000644",
8505
- "sha256": "942581a081da9134cb7038c61b646ddba6e25f2de15411fe59378a477ac05637"
8505
+ "sha256": "c9a7f39cf1f03e985466005a1857f570b4b3d449b0cbd8b3dfd0f7a7524a7c31"
8506
8506
  },
8507
8507
  {
8508
8508
  "path": "scripts/gds/classify-discipline.js",
@@ -9259,6 +9259,11 @@
9259
9259
  "mode": "0000644",
9260
9260
  "sha256": "084fa5c4da1e755fa0edde5ec770e5151a9f66a247bca222bc7a2366ccf61748"
9261
9261
  },
9262
+ {
9263
+ "path": "scripts/gds/review-sheet.js",
9264
+ "mode": "0000644",
9265
+ "sha256": "a0234b79890f9eae0fdc9d1910b723d61d21ca6b2888092b45084fb13c0c63c6"
9266
+ },
9262
9267
  {
9263
9268
  "path": "scripts/gds/role-pack-guard.js",
9264
9269
  "mode": "0000644",
@@ -9759,6 +9764,11 @@
9759
9764
  "mode": "0000644",
9760
9765
  "sha256": "be4fec619d5a0c76ba05bcc75042c1497f70da21b54a3d646884e2228f90b2ba"
9761
9766
  },
9767
+ {
9768
+ "path": "scripts/gds/visual-review-nudge.js",
9769
+ "mode": "0000644",
9770
+ "sha256": "a86b2cd427c9b52090d906b0b5febb9d8098fae1589e01828e4d7698d38db599"
9771
+ },
9762
9772
  {
9763
9773
  "path": "scripts/gds/worktree-claim-guard.js",
9764
9774
  "mode": "0000644",
@@ -10192,7 +10202,7 @@
10192
10202
  {
10193
10203
  "path": "src/module-api.js",
10194
10204
  "mode": "0000644",
10195
- "sha256": "19be294e50fce302cf4663c7776dfd8472eaddfb33805baf32b681603271cb77"
10205
+ "sha256": "5ed09f42ce4e04ca5f27bc3d4004ceab18bf604bd8a8fca72648afbf389b0935"
10196
10206
  },
10197
10207
  {
10198
10208
  "path": "src/module-loader/catalog.js",
@@ -12067,7 +12077,7 @@
12067
12077
  {
12068
12078
  "path": "tests/hall_board_world.mjs",
12069
12079
  "mode": "0000644",
12070
- "sha256": "264373b87ff2d022b54479ca06a4b01f7a052a8670237b79cbee5c6c907bb69e"
12080
+ "sha256": "48c6176c97d4cc167c57aeaf77baa78667731125c5ff2369564f08cbe9a146c7"
12071
12081
  },
12072
12082
  {
12073
12083
  "path": "tests/hall_builders_page_pass.mjs",
@@ -14614,6 +14624,11 @@
14614
14624
  "mode": "0000644",
14615
14625
  "sha256": "8a8df7e2a025029ffe7e18b7c7b18c6bd92a256822609931f0cd88628e446041"
14616
14626
  },
14627
+ {
14628
+ "path": "tests/visual_review.mjs",
14629
+ "mode": "0000644",
14630
+ "sha256": "6c583bd0cb3c2611044e5adad4c17a57d5aed89ba5daf921c355903df239a2ab"
14631
+ },
14617
14632
  {
14618
14633
  "path": "tests/wandering_prefs.mjs",
14619
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
- - `/builder-ship` with real `--notes` (what changed, which screens you looked at, in which modes) and a plain-language `--summary`.
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
 
@@ -2448,5 +2448,9 @@ is load-bearing: the script throws rather than guess if it is missing, and
2448
2448
  landed since 1.19.985 with no explicit bump. run 35903379073. (task 1002620)
2449
2449
  1.19.987 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2450
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)
2453
+ 1.19.989 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2454
+ landed since 1.19.988 with no explicit bump. run 35911149318. (task 1002620)
2451
2455
  ---------------------------------------------------------------------------
2452
2456
  ```
@@ -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
 
@@ -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) rather than asking a human to look. Verify, then share the 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
 
@@ -2661,6 +2661,178 @@ main [id] { scroll-margin-top: calc(var(--topbar-h) + 16px); }
2661
2661
  text-transform: var(--label-case);
2662
2662
  color: var(--ink-soft);
2663
2663
  }
2664
+ /* How old the task is (task 1002955, OTBKit.ageChipHtml). Quiet by default —
2665
+ it's orientation, not a call to act — but a task past the stale threshold
2666
+ steps up from faint to soft so a queue that has been sitting reads at a
2667
+ glance. The exact date is on the tooltip, hence `cursor: help`. Deliberately
2668
+ NOT --warn: that token is the verification vocabulary and as bare text on the
2669
+ dark card it loses contrast. --ink-soft steps up legibly in both modes. */
2670
+ .task__age {
2671
+ font-family: var(--font-mono);
2672
+ font-size: 11.5px;
2673
+ font-variant-numeric: tabular-nums;
2674
+ color: var(--ink-faint);
2675
+ white-space: nowrap;
2676
+ cursor: help;
2677
+ }
2678
+ .task__age--stale { color: var(--ink-soft); }
2679
+
2680
+ /* The four tag variants that carry meaning beyond "a fact". Each keeps the
2681
+ outlined shape style.css gives .task__tag and only changes what it says. */
2682
+ .task__tag--newcomer {
2683
+ background: var(--ok-weak);
2684
+ color: var(--ok);
2685
+ border-color: color-mix(in srgb, var(--ok) 30%, transparent);
2686
+ }
2687
+ .task__tag--mine {
2688
+ background: var(--accent-tint);
2689
+ color: var(--accent-ink);
2690
+ border-color: color-mix(in srgb, var(--accent) 35%, transparent);
2691
+ }
2692
+ /* task 1498 / ADR 0084: per-task rank floor. Outlined + emphasized (not a
2693
+ filled tag) so a gated task reads as "authority required", distinct from the
2694
+ filled newcomer tag. --warn is the verification vocabulary, kept on purpose. */
2695
+ .task__tag--rank {
2696
+ background: transparent;
2697
+ color: var(--warn);
2698
+ border-color: color-mix(in srgb, var(--warn) 45%, transparent);
2699
+ }
2700
+ /* task 1752: the open (no floor) state — muted so "Any rank" reads as "open to
2701
+ all", not as a warning like the gated --rank tag above. */
2702
+ .task__tag--rank-open {
2703
+ background: transparent;
2704
+ color: var(--ink-faint);
2705
+ border-color: var(--rule-soft);
2706
+ }
2707
+ /* ---- #616 / task 2170 / task 1003304: claim + copy on the claimable card ----
2708
+ The card's geometry, tags, credits figure, checkbox and copy button all live
2709
+ in this work-board section (they are the re-skin), and so do the primary door,
2710
+ the touches chip and the whole-card selection target (promoted from work.css,
2711
+ task 1003659, so their cascade position is the same on every page). */
2712
+
2713
+ /* The page's one primary action, and the only accent FILL on the board.
2714
+ --accent-on is the dark ink that goes on the salmon: white on it fails AA and
2715
+ DESIGN.md bans it outright, which is what the old `color: #fff` was. */
2716
+ .task__claim {
2717
+ display: inline-flex;
2718
+ align-items: center;
2719
+ justify-content: center;
2720
+ min-height: 34px;
2721
+ min-width: 74px;
2722
+ padding: 0 16px;
2723
+ background: var(--accent);
2724
+ border: 0;
2725
+ color: var(--accent-on);
2726
+ border-radius: var(--radius-pill);
2727
+ font-size: 13.5px;
2728
+ font-weight: 500;
2729
+ text-transform: lowercase;
2730
+ cursor: pointer;
2731
+ white-space: nowrap;
2732
+ transition: background 150ms, opacity 150ms;
2733
+ }
2734
+ .task__claim:hover:not(:disabled) { background: var(--accent-lift); color: var(--accent-on); }
2735
+ .task__claim:disabled { background: var(--glass); color: var(--ink-faint); cursor: default; }
2736
+
2737
+ /* Files a claimable task is responsible for — a compact count chip in the meta
2738
+ row; the full path list rides the tooltip (task 2170 density pass). The mono
2739
+ fact voice: this is a number the ledger produced. */
2740
+ .task__touches {
2741
+ font-family: var(--font-mono);
2742
+ font-size: 11.5px;
2743
+ font-variant-numeric: tabular-nums;
2744
+ color: var(--ink-faint);
2745
+ white-space: nowrap;
2746
+ cursor: help;
2747
+ }
2748
+
2749
+ /* task 1753: the whole card is clickable → toggles selection (task 1869). */
2750
+ .task { cursor: pointer; }
2751
+
2752
+ @media (max-width: 640px) {
2753
+ .task__actions { grid-column: 1 / -1; justify-self: start; padding-top: 6px; }
2754
+ }
2755
+
2756
+ /* ---- task 1759: the bottom bulk-action toolbar (promoted from work.css, task 1003659) ---- */
2757
+ .bulk-toolbar {
2758
+ position: fixed;
2759
+ left: var(--sidebar-w);
2760
+ right: 0;
2761
+ bottom: 0;
2762
+ /* The chrome tier, not above it: the toast (z 100) has to stay readable over
2763
+ this bar, and the drawer + its scrim have to cover it. */
2764
+ z-index: var(--z-topbar);
2765
+ display: flex;
2766
+ align-items: center;
2767
+ gap: 16px;
2768
+ flex-wrap: wrap;
2769
+ padding: 12px 20px;
2770
+ /* TWO layers, and the bottom one is why. The topbar and the rail can be bare
2771
+ --glass because the page GROUND is behind them; this bar sits over the task
2772
+ cards, and in light --glass is a 5% ink tint with the blur at 0px, so the
2773
+ card underneath read straight through it and the buttons landed on top of a
2774
+ task title (looked at, 1280w light). Painting --bg under the tint makes the
2775
+ bar opaque without giving up the glass character or spending a literal. */
2776
+ background: linear-gradient(var(--glass), var(--glass)), var(--bg);
2777
+ border-top: var(--line) solid var(--glass-edge);
2778
+ backdrop-filter: blur(var(--glass-blur));
2779
+ -webkit-backdrop-filter: blur(var(--glass-blur));
2780
+ color: var(--ink);
2781
+ }
2782
+ .bulk-toolbar[hidden] { display: none; }
2783
+ .bulk-toolbar__count {
2784
+ font-family: var(--label-face);
2785
+ font-size: var(--label-size);
2786
+ letter-spacing: var(--label-space);
2787
+ text-transform: var(--label-case);
2788
+ color: var(--ink);
2789
+ white-space: nowrap;
2790
+ }
2791
+ .bulk-toolbar__actions { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-left: auto; }
2792
+ .bulk-toolbar__btn {
2793
+ display: inline-flex;
2794
+ align-items: center;
2795
+ justify-content: center;
2796
+ min-height: 30px;
2797
+ padding: 0 14px;
2798
+ background: var(--glass);
2799
+ border: var(--line) solid var(--glass-edge);
2800
+ color: var(--ink);
2801
+ border-radius: var(--radius-pill);
2802
+ font-size: 13px;
2803
+ text-transform: lowercase;
2804
+ cursor: pointer;
2805
+ white-space: nowrap;
2806
+ transition: background 150ms, opacity 150ms;
2807
+ }
2808
+ .bulk-toolbar__btn:hover:not(:disabled) { background: var(--glass-strong); }
2809
+ .bulk-toolbar__btn:disabled { color: var(--ink-faint); border-color: var(--rule-soft); cursor: not-allowed; }
2810
+ .bulk-toolbar__clear {
2811
+ min-height: 30px;
2812
+ padding: 0 10px;
2813
+ background: transparent;
2814
+ border: none;
2815
+ color: var(--ink-soft);
2816
+ font-size: 13px;
2817
+ text-transform: lowercase;
2818
+ text-decoration: underline;
2819
+ text-underline-offset: 4px;
2820
+ cursor: pointer;
2821
+ }
2822
+ .bulk-toolbar__clear:hover { color: var(--ink); }
2823
+
2824
+ /* The shell's .app-content already ends in 108px of bottom air at desktop
2825
+ width, which is what clears the bar — but at 768 it drops to 48px for the
2826
+ phone, and there the bar is TALLER (its actions wrap to their own row). So
2827
+ the phone width buys the room back, scoped to this page: goals.html loads
2828
+ this sheet too and has no bulk toolbar to clear. */
2829
+ @media (max-width: 768px) {
2830
+ /* At the drawer breakpoint the sidebar leaves the flow, so the bar spans. */
2831
+ .bulk-toolbar { left: 0; padding: 10px 14px; gap: 10px; }
2832
+ .bulk-toolbar__actions { margin-left: 0; width: 100%; }
2833
+ body[data-page="work"] .app-content { padding-bottom: 200px; }
2834
+ }
2835
+
2664
2836
  .task__credits { display: flex; flex-direction: column; align-items: flex-end; gap: 2px; padding-top: 2px; }
2665
2837
  /* A credit figure is DATA, not live — the display face in ink, never accent
2666
2838
  (DESIGN.md, The One Warm Thing Rule + The Tabular Rule). */
@@ -46,93 +46,6 @@
46
46
  color: var(--ink-faint);
47
47
  }
48
48
 
49
- /* ---- #616 / task 2170 / task 1003304: claim + copy on the claimable card ----
50
- The card's geometry, tags, credits figure, checkbox and copy button all live
51
- in style.css's work-board section now (they are the re-skin). What stays here
52
- is the page-local behaviour: the primary door, the two meta chips, and the
53
- fact that the whole card is a selection target. */
54
-
55
- /* The page's one primary action, and the only accent FILL on the board.
56
- --accent-on is the dark ink that goes on the salmon: white on it fails AA and
57
- DESIGN.md bans it outright, which is what the old `color: #fff` was. */
58
- .task__claim {
59
- display: inline-flex;
60
- align-items: center;
61
- justify-content: center;
62
- min-height: 34px;
63
- min-width: 74px;
64
- padding: 0 16px;
65
- background: var(--accent);
66
- border: 0;
67
- color: var(--accent-on);
68
- border-radius: var(--radius-pill);
69
- font-size: 13.5px;
70
- font-weight: 500;
71
- text-transform: lowercase;
72
- cursor: pointer;
73
- white-space: nowrap;
74
- transition: background 150ms, opacity 150ms;
75
- }
76
- .task__claim:hover:not(:disabled) { background: var(--accent-lift); color: var(--accent-on); }
77
- .task__claim:disabled { background: var(--glass); color: var(--ink-faint); cursor: default; }
78
-
79
- /* Files a claimable task is responsible for — a compact count chip in the meta
80
- row; the full path list rides the tooltip (task 2170 density pass). The mono
81
- fact voice: this is a number the ledger produced. */
82
- .task__touches {
83
- font-family: var(--font-mono);
84
- font-size: 11.5px;
85
- font-variant-numeric: tabular-nums;
86
- color: var(--ink-faint);
87
- white-space: nowrap;
88
- cursor: help;
89
- }
90
-
91
- /* How old the task is (task 1002955, OTBKit.ageChipHtml). Quiet by default —
92
- it's orientation, not a call to act — but a task past the stale threshold
93
- steps up from faint to soft so a queue that has been sitting reads at a
94
- glance. The exact date is on the tooltip, hence `cursor: help`. Deliberately
95
- NOT --warn: that token is the verification vocabulary and as bare text on the
96
- dark card it loses contrast. --ink-soft steps up legibly in both modes. */
97
- .task__age {
98
- font-family: var(--font-mono);
99
- font-size: 11.5px;
100
- font-variant-numeric: tabular-nums;
101
- color: var(--ink-faint);
102
- white-space: nowrap;
103
- cursor: help;
104
- }
105
- .task__age--stale { color: var(--ink-soft); }
106
-
107
- /* The three tag variants that carry meaning beyond "a fact". Each keeps the
108
- outlined shape style.css gives .task__tag and only changes what it says. */
109
- .task__tag--newcomer {
110
- background: var(--ok-weak);
111
- color: var(--ok);
112
- border-color: color-mix(in srgb, var(--ok) 30%, transparent);
113
- }
114
- .task__tag--mine {
115
- background: var(--accent-tint);
116
- color: var(--accent-ink);
117
- border-color: color-mix(in srgb, var(--accent) 35%, transparent);
118
- }
119
- /* task 1498 / ADR 0084: per-task rank floor. Outlined + emphasized (not a
120
- filled tag) so a gated task reads as "authority required", distinct from the
121
- filled newcomer tag. --warn is the verification vocabulary, kept on purpose. */
122
- .task__tag--rank {
123
- background: transparent;
124
- color: var(--warn);
125
- border-color: color-mix(in srgb, var(--warn) 45%, transparent);
126
- }
127
- /* task 1752: the open (no floor) state — muted so "Any rank" reads as "open to
128
- all", not as a warning like the gated --rank tag above. */
129
- .task__tag--rank-open {
130
- background: transparent;
131
- color: var(--ink-faint);
132
- border-color: var(--rule-soft);
133
- }
134
- /* task 1753: the whole card is clickable → toggles selection (task 1869). */
135
- .task { cursor: pointer; }
136
49
  .backlog-count {
137
50
  font-family: var(--font-mono);
138
51
  font-size: 11px;
@@ -205,11 +118,8 @@
205
118
  .history__goal { display: inline-block; margin-top: 3px; color: var(--ink-soft); font-size: 12.5px; }
206
119
  .history__when { color: var(--ink-soft); font-size: 12.5px; white-space: nowrap; font-variant-numeric: tabular-nums; }
207
120
 
208
- @media (max-width: 640px) {
209
- .task__actions { grid-column: 1 / -1; justify-self: start; padding-top: 6px; }
210
- }
211
121
 
212
- /* ---- task 1759: multi-select + the bottom bulk-action toolbar ----
122
+ /* ---- task 1759: multi-select checkbox (the toolbar itself is in style.css) ----
213
123
  Re-cut for the 24px floor and the world (task 1003304). The floating dark
214
124
  pill is gone: it painted an INVERTED surface (background: var(--ink)) with
215
125
  three rgba(255,255,255,…) literals on top, which was correct only for as long
@@ -250,85 +160,6 @@
250
160
  transform: rotate(42deg);
251
161
  }
252
162
 
253
- .bulk-toolbar {
254
- position: fixed;
255
- left: var(--sidebar-w);
256
- right: 0;
257
- bottom: 0;
258
- /* The chrome tier, not above it: the toast (z 100) has to stay readable over
259
- this bar, and the drawer + its scrim have to cover it. */
260
- z-index: var(--z-topbar);
261
- display: flex;
262
- align-items: center;
263
- gap: 16px;
264
- flex-wrap: wrap;
265
- padding: 12px 20px;
266
- /* TWO layers, and the bottom one is why. The topbar and the rail can be bare
267
- --glass because the page GROUND is behind them; this bar sits over the task
268
- cards, and in light --glass is a 5% ink tint with the blur at 0px, so the
269
- card underneath read straight through it and the buttons landed on top of a
270
- task title (looked at, 1280w light). Painting --bg under the tint makes the
271
- bar opaque without giving up the glass character or spending a literal. */
272
- background: linear-gradient(var(--glass), var(--glass)), var(--bg);
273
- border-top: var(--line) solid var(--glass-edge);
274
- backdrop-filter: blur(var(--glass-blur));
275
- -webkit-backdrop-filter: blur(var(--glass-blur));
276
- color: var(--ink);
277
- }
278
- .bulk-toolbar[hidden] { display: none; }
279
- .bulk-toolbar__count {
280
- font-family: var(--label-face);
281
- font-size: var(--label-size);
282
- letter-spacing: var(--label-space);
283
- text-transform: var(--label-case);
284
- color: var(--ink);
285
- white-space: nowrap;
286
- }
287
- .bulk-toolbar__actions { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-left: auto; }
288
- .bulk-toolbar__btn {
289
- display: inline-flex;
290
- align-items: center;
291
- justify-content: center;
292
- min-height: 30px;
293
- padding: 0 14px;
294
- background: var(--glass);
295
- border: var(--line) solid var(--glass-edge);
296
- color: var(--ink);
297
- border-radius: var(--radius-pill);
298
- font-size: 13px;
299
- text-transform: lowercase;
300
- cursor: pointer;
301
- white-space: nowrap;
302
- transition: background 150ms, opacity 150ms;
303
- }
304
- .bulk-toolbar__btn:hover:not(:disabled) { background: var(--glass-strong); }
305
- .bulk-toolbar__btn:disabled { color: var(--ink-faint); border-color: var(--rule-soft); cursor: not-allowed; }
306
- .bulk-toolbar__clear {
307
- min-height: 30px;
308
- padding: 0 10px;
309
- background: transparent;
310
- border: none;
311
- color: var(--ink-soft);
312
- font-size: 13px;
313
- text-transform: lowercase;
314
- text-decoration: underline;
315
- text-underline-offset: 4px;
316
- cursor: pointer;
317
- }
318
- .bulk-toolbar__clear:hover { color: var(--ink); }
319
-
320
- /* The shell's .app-content already ends in 108px of bottom air at desktop
321
- width, which is what clears the bar — but at 768 it drops to 48px for the
322
- phone, and there the bar is TALLER (its actions wrap to their own row). So
323
- the phone width buys the room back, scoped to this page: goals.html loads
324
- this sheet too and has no bulk toolbar to clear. */
325
- @media (max-width: 768px) {
326
- /* At the drawer breakpoint the sidebar leaves the flow, so the bar spans. */
327
- .bulk-toolbar { left: 0; padding: 10px 14px; gap: 10px; }
328
- .bulk-toolbar__actions { margin-left: 0; width: 100%; }
329
- body[data-page="work"] .app-content { padding-bottom: 200px; }
330
- }
331
-
332
163
  /* ---- "Only mine" lens (the board's tab-wide filter; goal board's To-do bar
333
164
  borrows the same class) --------------------------------------------------
334
165
  Deliberately QUIETER than the filter bar below it: a checkbox in the label
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.987",
3
+ "version": "1.19.989",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.987",
9
+ "version": "1.19.989",
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.987",
3
+ "version": "1.19.989",
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",
@@ -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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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 };
@@ -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.987'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
74
+ const CORE_VERSION = '1.19.989'; // 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');
@@ -104,7 +104,7 @@ test('a prose link buys BOTH axes — padding for width, negative margin to give
104
104
  // ---- 3. the world's own bans ------------------------------------------------
105
105
 
106
106
  test('nothing on the board paints text white on the accent', () => {
107
- const claim = rule(workCss, '.task__claim');
107
+ const claim = rule(styleCss, '.task__claim');
108
108
  assert.ok(claim, '.task__claim has a rule');
109
109
  assert.match(claim, /background:\s*var\(--accent\)/, 'the claim button is the page\'s one accent fill');
110
110
  assert.match(claim, /color:\s*var\(--accent-on\)/,
@@ -115,6 +115,13 @@ test('nothing on the board paints text white on the accent', () => {
115
115
  'the literal this task removed must not come back');
116
116
  });
117
117
 
118
+ test('the shared task + bulk-toolbar rules live in style.css (the shared base), not the page-local work.css', () => {
119
+ for (const sel of ['.task__age', '.task__age--stale', '.task__tag--newcomer', '.task__tag--mine', '.task__tag--rank', '.task__tag--rank-open', '.task__claim', '.task__touches', '.task', '.bulk-toolbar', '.bulk-toolbar__count', '.bulk-toolbar__actions', '.bulk-toolbar__btn', '.bulk-toolbar__clear']) {
120
+ assert.ok(rule(styleCss, sel), `style.css carries ${sel}, beside .task__tag`);
121
+ assert.equal(rule(workCss, sel), null, `work.css must not restate ${sel}`);
122
+ }
123
+ });
124
+
118
125
  test('work.css spends no compat alias — that was the sheet\'s removal list', () => {
119
126
  const aliases = ['--surface', '--surface-2', '--border', '--border-strong', '--accent-weak', '--accent-text'];
120
127
  const bare = workCss.replace(/\/\*[\s\S]*?\*\//g, '');
@@ -124,7 +131,7 @@ test('work.css spends no compat alias — that was the sheet\'s removal list', (
124
131
  });
125
132
 
126
133
  test('the bulk toolbar carries no rgba literal and is opaque over the cards', () => {
127
- const bar = rule(workCss, '.bulk-toolbar');
134
+ const bar = rule(styleCss, '.bulk-toolbar');
128
135
  assert.ok(bar, '.bulk-toolbar has a rule');
129
136
  assert.doesNotMatch(bar, /rgba?\(/i,
130
137
  'the three rgba(255,255,255,…) literals went with the inverted --ink surface that needed them');
@@ -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 &amp; 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();