@bongos/core 1.21.109 → 1.21.111

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.
@@ -0,0 +1,227 @@
1
+ /* craft-review.css — artist review mode (task 1004686; craft-mode.js draws the
2
+ menu, craft-review.js the wall). Imported by style.css, beside page-notes.css.
3
+
4
+ The Review button becomes a split pill: Review still opens the feedback doc,
5
+ and the chevron (or, where a pointer can hover, the whole pill) opens a menu
6
+ of the four experiences. While an artist reviews one, small black-and-white
7
+ doodles in the sky's hand are scribbled into the top bar's empty space.
8
+
9
+ Every colour is a token (tests/hall_audit.mjs holds that). Every motion is
10
+ CSS on transform and opacity, and the hall's reduced-motion kill switch
11
+ (style.css) leaves three doodles standing, finished, with no pen.
12
+
13
+ An @import lands BEFORE style.css's own rules, so a rule here that restyles
14
+ .craft-tool must out-rank it (two classes), or style.css silently wins. */
15
+
16
+ /* ---------- the split pill ---------- */
17
+ .craft-review {
18
+ position: relative;
19
+ display: inline-flex;
20
+ align-items: center;
21
+ flex-shrink: 0;
22
+ border-radius: var(--radius-pill);
23
+ box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--craft, var(--ink)) 40%, transparent);
24
+ }
25
+ .craft-review > .craft-tool {
26
+ box-shadow: none;
27
+ border-radius: var(--radius-pill) 0 0 var(--radius-pill);
28
+ }
29
+ .craft-review__more {
30
+ display: inline-flex;
31
+ align-items: center;
32
+ justify-content: center;
33
+ width: 24px;
34
+ height: 32px;
35
+ padding: 0 4px 0 0;
36
+ border: 0;
37
+ border-radius: 0 var(--radius-pill) var(--radius-pill) 0;
38
+ background: none;
39
+ color: var(--craft, var(--ink-soft));
40
+ cursor: pointer;
41
+ }
42
+ .craft-review__more svg { width: 12px; height: 12px; transition: transform 200ms cubic-bezier(.2, .7, .3, 1); }
43
+ .craft-review__more[aria-expanded="true"] svg { transform: rotate(180deg); }
44
+ .craft-review__more:hover { background: color-mix(in srgb, var(--craft, var(--ink)) 12%, transparent); }
45
+ .craft-review__more:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; }
46
+ @media (min-width: 1181px) {
47
+ .craft-review > .craft-tool { padding-right: 6px; }
48
+ }
49
+ @media (max-width: 1180px) {
50
+ .craft-review > .craft-tool { width: 30px; }
51
+ }
52
+
53
+ /* Reviewing: the pill takes a wash of the experience's colour, says
54
+ "Reviewing", and a drop of wet paint hangs from it. */
55
+ .craft-tool.craft-tool--reviewing { position: relative; background: color-mix(in srgb, var(--craft, var(--sea)) 14%, transparent); }
56
+ .craft-tool.craft-tool--reviewing::after {
57
+ content: '';
58
+ position: absolute;
59
+ left: 13px;
60
+ bottom: -4px;
61
+ width: 4px;
62
+ height: 7px;
63
+ border-radius: 0 0 3px 3px;
64
+ background: var(--craft, var(--sea));
65
+ }
66
+
67
+ /* ---------- the menu ---------- */
68
+ .craft-review__menu {
69
+ position: absolute;
70
+ top: calc(100% + 8px);
71
+ right: 0;
72
+ z-index: var(--z-drawer);
73
+ display: flex;
74
+ flex-direction: column;
75
+ gap: 2px;
76
+ min-width: 248px;
77
+ padding: 6px;
78
+ box-sizing: border-box;
79
+ background: var(--bg-card);
80
+ border-radius: var(--radius);
81
+ box-shadow: 0 0 0 1px var(--glass-edge), var(--shadow-pop);
82
+ }
83
+ .craft-review__menu[hidden] { display: none; }
84
+ /* A bridge over the gap, so a pointer travelling down to the menu never
85
+ leaves the pill and the hover menu does not close under it. */
86
+ .craft-review__menu::before { content: ''; position: absolute; left: 0; right: 0; top: -8px; height: 8px; }
87
+ .craft-review__head {
88
+ margin: 8px 12px 2px;
89
+ font-family: var(--label-face);
90
+ font-size: 11px;
91
+ letter-spacing: .12em;
92
+ text-transform: uppercase;
93
+ color: var(--ink-faint);
94
+ }
95
+ .craft-review__item {
96
+ display: flex;
97
+ align-items: center;
98
+ gap: 10px;
99
+ min-height: 36px;
100
+ padding: 0 12px;
101
+ border: 0;
102
+ border-radius: var(--radius-sm);
103
+ background: none;
104
+ color: var(--ink);
105
+ font: inherit;
106
+ font-size: 14px;
107
+ text-align: left;
108
+ white-space: nowrap;
109
+ cursor: pointer;
110
+ }
111
+ .craft-review__item:hover { background: color-mix(in srgb, var(--craft, var(--ink)) 10%, transparent); }
112
+ .craft-review__item:focus-visible { outline: 2px solid var(--focus); outline-offset: -2px; }
113
+ .craft-review__icon { width: 16px; height: 16px; flex-shrink: 0; color: var(--ink-soft); }
114
+ .craft-review__item[aria-checked="true"] { color: var(--craft, var(--ink)); font-weight: 500; }
115
+ .craft-review__item[aria-checked="true"] .craft-review__icon { color: var(--craft, var(--ink)); }
116
+ .craft-review__check { width: 14px; height: 14px; margin-left: auto; flex-shrink: 0; }
117
+ .craft-review__item--stop { margin-top: 4px; border-top: 1px solid var(--rule-soft); border-radius: 0 0 var(--radius-sm) var(--radius-sm); }
118
+
119
+ /* A phone: no hover and no room for the chevron, so Review itself opens the
120
+ menu (craft-mode.js), and the menu spans the bar instead of hanging off a
121
+ pill that may sit near the left edge. */
122
+ @media (max-width: 640px) {
123
+ .craft-review { position: static; }
124
+ .craft-review__more { display: none; }
125
+ .craft-review > .craft-tool { width: 28px; border-radius: var(--radius-pill); }
126
+ .craft-review__menu { top: calc(100% + 6px); left: 12px; right: 12px; min-width: 0; }
127
+ .craft-review__item { min-height: 44px; }
128
+ }
129
+ /* Under 360px the bar holds two tools, not three: reviewing Ideating, its
130
+ own Ideas button folds away (craft-mode.js marks it a guest). */
131
+ @media (max-width: 359px) { .craft-tool.craft-tool--guest { display: none; } }
132
+
133
+ /* ---------- the scribble wall ---------- */
134
+ /* The top bar's empty space, between the title and the controls. Where none
135
+ is left (a phone), the Review pill still says "Reviewing". */
136
+ .craft-graffiti {
137
+ position: relative;
138
+ flex: 1 1 0;
139
+ min-width: 0;
140
+ align-self: stretch;
141
+ overflow: hidden;
142
+ pointer-events: none;
143
+ container: gx / inline-size;
144
+ }
145
+ @media (max-width: 640px) { .craft-graffiti { display: none; } }
146
+ /* Small walls keep the small doodles; a wall too tight for even one shows none. */
147
+ @container gx (max-width: 58px) { .gx-tag--wide { display: none; } }
148
+ @container gx (max-width: 34px) { .gx-tag { display: none; } }
149
+
150
+ /* Six doodles in the sky's hand (craft-review.js), each scribbled at its own
151
+ spot and height across the wall: a new one every five seconds of a
152
+ thirty-second round, each staying about ten, so two or three are up at once. */
153
+ .gx-tag {
154
+ position: absolute;
155
+ top: calc(50% - 12px + var(--gx-dy, 0px));
156
+ width: var(--gx-w);
157
+ height: 24px;
158
+ --gx-delay: 0s;
159
+ }
160
+ .gx-tag--1 { left: calc((100% - var(--gx-w)) * .06); --gx-dy: -7px; }
161
+ .gx-tag--2 { left: calc((100% - var(--gx-w)) * .55); --gx-dy: 6px; --gx-delay: 5s; }
162
+ .gx-tag--3 { left: calc((100% - var(--gx-w)) * .3); --gx-dy: 4px; --gx-delay: 10s; }
163
+ .gx-tag--4 { left: calc((100% - var(--gx-w)) * .84); --gx-dy: -6px; --gx-delay: 15s; }
164
+ .gx-tag--5 { left: calc((100% - var(--gx-w)) * .42); --gx-dy: -5px; --gx-delay: 20s; }
165
+ .gx-tag--6 { left: calc((100% - var(--gx-w)) * .72); --gx-dy: 7px; --gx-delay: 25s; }
166
+
167
+ /* The wet line: the window slides in from the left while the drawing inside
168
+ it slides the other way by the same amount, so the doodle stays put and is
169
+ uncovered left to right. The pen tip rides that edge, jittering up and
170
+ down as it scribbles. */
171
+ .gx-win, .gx-track { position: absolute; inset: -2px -3px; }
172
+ .gx-win { overflow: hidden; }
173
+ .gx-ink { position: absolute; inset: 0; padding: 2px 3px; }
174
+ .gx-svg { display: block; overflow: visible; }
175
+ .gx-win, .gx-ink, .gx-track {
176
+ animation-duration: 30s;
177
+ animation-timing-function: linear;
178
+ animation-iteration-count: infinite;
179
+ animation-fill-mode: both;
180
+ animation-delay: var(--gx-delay);
181
+ }
182
+ .gx-win { animation-name: gx-win; }
183
+ .gx-ink { animation-name: gx-ink; }
184
+ .gx-track { animation-name: gx-track; opacity: 0; }
185
+ /* The rest frame (and the reduced-motion one): three doodles, finished. */
186
+ .gx-tag:not(.gx-tag--1):not(.gx-tag--2):not(.gx-tag--4) .gx-win { opacity: 0; }
187
+
188
+ @keyframes gx-win {
189
+ 0% { transform: translateX(-100%); opacity: 1; }
190
+ 4% { transform: translateX(0); }
191
+ 28% { opacity: 1; }
192
+ 32%, 100% { transform: translateX(0); opacity: 0; }
193
+ }
194
+ @keyframes gx-ink {
195
+ 0% { transform: translateX(100%); }
196
+ 4%, 100% { transform: translateX(0); }
197
+ }
198
+ /* The pen waits unseen: a doodle not yet begun shows no tip. */
199
+ @keyframes gx-track {
200
+ 0% { transform: translateX(-100%); opacity: 0; }
201
+ .3% { opacity: 1; }
202
+ 4% { transform: translateX(0); opacity: 1; }
203
+ 5%, 100% { transform: translateX(0); opacity: 0; }
204
+ }
205
+
206
+ .gx-nib {
207
+ position: absolute;
208
+ right: -1.5px;
209
+ top: calc(50% - 1.5px);
210
+ width: 3px;
211
+ height: 3px;
212
+ border-radius: 999px;
213
+ background: var(--ink);
214
+ animation: gx-scribble .16s ease-in-out infinite alternate;
215
+ }
216
+ @keyframes gx-scribble {
217
+ from { transform: translateY(-7px); }
218
+ to { transform: translateY(7px); }
219
+ }
220
+
221
+ /* The ink: hairlines and dots, black on the light bar and white on the dark
222
+ one, with the sky's own ladder for the fainter marks. */
223
+ .gx-ln { fill: none; stroke: var(--ink); stroke-width: 1.3px; stroke-linecap: round; stroke-linejoin: round; }
224
+ .gx-ln--soft { stroke: color-mix(in srgb, var(--ink) 55%, transparent); }
225
+ .gx-ln--dash { stroke: color-mix(in srgb, var(--ink) 55%, transparent); stroke-width: 1px; stroke-dasharray: 2 2.5; }
226
+ .gx-pt { fill: var(--ink); }
227
+ .gx-halo { fill: none; stroke: color-mix(in srgb, var(--ink) 28%, transparent); stroke-width: 1px; }
@@ -0,0 +1,64 @@
1
+ // craft-review.js — the scribble wall of artist review mode (task 1004686,
2
+ // goal 1000095). Loaded on demand by craft-mode.js, only while an artist is
3
+ // reviewing an experience: wallHtml() returns small doodles a pen scribbles,
4
+ // one after another, into the top bar's empty space, so the reviewer can see
5
+ // at a glance that the hall they are looking at is under review.
6
+ //
7
+ // The doodles are drawn in the sky's hand (thinking.css, ADR 0331): hairlines,
8
+ // small stars, dashed orbits and constellations, in ink alone — black on the
9
+ // light bar, white on the dark one (owner, 2026-10-09: "small black white
10
+ // graffiti scribbling", not the first cut's big painted tags).
11
+ //
12
+ // Markup only, and decorative (the wall is aria-hidden; the Review button and
13
+ // its menu say the state in words). Every motion is CSS in craft-review.css:
14
+ // no motion in JavaScript (DESIGN.md, the Kill Switch Rule), so a reader who
15
+ // asks for reduced motion sees three finished doodles standing still.
16
+ (function () {
17
+ 'use strict';
18
+
19
+ var H = 24;
20
+
21
+ // The six doodles, in the order they are scribbled. Each is { w, wide, svg }:
22
+ // `wide` ones fold away when the wall is too tight for them.
23
+ var DOODLES = [
24
+ // A constellation: five stars joined by a hairline, one with a halo.
25
+ { w: 56, wide: true, svg:
26
+ '<path class="gx-ln gx-ln--soft" d="M3 17 16 8l12 5 13-9 12 7"/>' +
27
+ '<circle class="gx-pt" cx="3" cy="17" r="1.5"/><circle class="gx-pt" cx="16" cy="8" r="1.5"/>' +
28
+ '<circle class="gx-pt" cx="28" cy="13" r="1.5"/><circle class="gx-pt" cx="53" cy="11" r="1.5"/>' +
29
+ '<circle class="gx-pt" cx="41" cy="4" r="2"/><circle class="gx-halo" cx="41" cy="4" r="4.5"/>' },
30
+ // A ringed protostar on a dashed orbit, a moon riding it.
31
+ { w: 40, svg:
32
+ '<circle class="gx-ln" cx="20" cy="12" r="5"/>' +
33
+ '<ellipse class="gx-ln gx-ln--dash" cx="20" cy="12" rx="16" ry="4.5" transform="rotate(-16 20 12)"/>' +
34
+ '<circle class="gx-pt" cx="35" cy="6" r="1.3"/>' },
35
+ // A shooting star: a scribbled trail and a four-point head.
36
+ { w: 52, wide: true, svg:
37
+ '<path class="gx-ln gx-ln--soft" d="M2 21c9-2 18-6 31-12"/><path class="gx-ln gx-ln--soft" d="M10 22c6-1.5 12-4 19-7.5"/>' +
38
+ '<path class="gx-ln" d="M42 1.5v9M37.5 6h9"/><circle class="gx-pt" cx="42" cy="6" r="1.4"/>' },
39
+ // A crescent and a spark beside it.
40
+ { w: 32, svg:
41
+ '<path class="gx-ln" d="M16 3.5a8.5 8.5 0 1 0 9.5 12.6A7 7 0 0 1 16 3.5z"/>' +
42
+ '<path class="gx-ln" d="M27 2.5v5M24.5 5h5"/>' },
43
+ // A scribbled orbit loop, the pen going round and round.
44
+ { w: 40, svg:
45
+ '<path class="gx-ln" d="M3 15c3-8 11-9 13-4s-5 8-7 4 4-9 11-8 10 6 7 9-8 1-5-4 9-6 13 0"/>' },
46
+ // A cluster of sparks, big to small.
47
+ { w: 30, svg:
48
+ '<path class="gx-ln" d="M8 2.5v11M2.5 8h11"/><path class="gx-ln gx-ln--soft" d="M21 13v7M17.5 16.5h7"/>' +
49
+ '<circle class="gx-pt" cx="27" cy="5" r="1.2"/><circle class="gx-pt" cx="14" cy="20" r="1"/>' },
50
+ ];
51
+
52
+ function wallHtml() {
53
+ return DOODLES.map(function (d, i) {
54
+ return '<span class="gx-tag gx-tag--' + (i + 1) + (d.wide ? ' gx-tag--wide' : '') + '" style="--gx-w:' + d.w + 'px">' +
55
+ '<span class="gx-win"><span class="gx-ink">' +
56
+ '<svg class="gx-svg" viewBox="0 0 ' + d.w + ' ' + H + '" width="' + d.w + '" height="' + H + '" aria-hidden="true">' + d.svg + '</svg>' +
57
+ '</span></span>' +
58
+ '<span class="gx-track"><span class="gx-nib"></span></span>' +
59
+ '</span>';
60
+ }).join('');
61
+ }
62
+
63
+ window.OTBCraftReview = { wallHtml: wallHtml, COUNT: DOODLES.length };
64
+ })();
@@ -57,6 +57,7 @@
57
57
  body declaration beats an inherited :root value at every width and a token
58
58
  tier that carries a breakpoint silently defeats its own media query. */
59
59
  @import url("page-notes.css");
60
+ @import url("craft-review.css");
60
61
 
61
62
  :root {
62
63
  color-scheme: light;
@@ -57,3 +57,11 @@ Covers: every shelled hall page (`craft-mode.js`, `shell.js`, `style.css`), and
57
57
  - **The panel lists the ideas already filed from this page** (`GET /page-notes/ideas?page_path=`): open and promoted ideas whose body carries the page's link, from the lightbulb (`](/page)`) or the palette (`](/page?note=`). That link is the only record of where an idea came from, so `notes.pageLinks` and the two idea bodies are pinned together in `tests/lightbulb.mjs`.
58
58
  - **Unsent text waits in this browser only**, per page (`localStorage 'hall-lightbulb:<path>'`), and is forgotten once it files.
59
59
  - `tests/lightbulb.mjs` runs the real routes and the real script over a small DOM.
60
+
61
+ ## Artist review mode (task 1004686)
62
+
63
+ - **Review is a split pill** (owner, 2026-10-09): pressing Review still opens the feedback doc; hovering it with a mouse, or pressing its chevron, opens a menu: "Write feedback on this page", then the four experiences in the owner's order and words (Ideator, Governing, Building, Artistry experience), then "Stop reviewing" while a review is on. Hover is asked of the pointer (`pointerType === 'mouse'`), never `(hover: hover)`: a touch laptop can report no hover at all. On a phone (<=640px) the chevron folds away and a tap on Review opens the menu.
64
+ - **Picking one makes the hall wear that mode** (`html[data-craft]`, so its tabs, look, eyebrow and tallies follow) and stamps `html[data-craft-review]`. The artist's own mode stays Artistry: `sessionStorage['hall-craft-review']` holds the experience for the session only, and is ignored unless the mode is Artistry. Choosing any mode from the switch or the picker ends it. The artist keeps Review and Edit words; the experience's own tools follow them, marked `craft-tool--guest` so a phone under 360px can fold them away (three tools pushed 320px 36px sideways; Artistry alone overflows 4px there today, before and after).
65
+ - **The scribble wall** (`craft-review.js`, loaded on demand; `craft-review.css`, imported by `style.css`): a flexible box in the top bar's empty space where a pen tip scribbles six small doodles in the sky's hand (thinking.css: hairlines, stars, a dashed orbit, a constellation, a crescent, a shooting star, a looped scribble, sparks), each at its own spot and height, a new one every 5s of a 30s round and each up about 10s, so two or three show at once. **Ink alone** (owner, 2026-10-09, after a first cut of big painted colour tags: "small black white graffiti scribbling"): every mark is `--ink`, black on the light bar and white on the dark, with the sky's fainter ladder for trails and orbits; the test holds the wall section to that one token. No doodle is wider than 56px or taller than 24px; the two widest fold away on a wall under 58px (Governing at 1440px keeps about 60), all of them under 34px and on a phone. The wipe is two opposite `translateX` moves, so every motion is transform or opacity in CSS, and the kill switch leaves three doodles standing, finished, with no pen. A doodle not yet begun keeps its pen unseen (`gx-track` starts at opacity 0), or each waiting tip shows as a stray dot.
66
+ - **An @import lands before style.css's own rules**, so a rule in `craft-review.css` that restyles `.craft-tool` needs two classes, or `.craft-tool` silently wins (it did, for the reviewing wash and the guest fold, until measured).
67
+ - `tests/craft_review_mode.mjs` runs the real `craft-mode.js` and `craft-review.js` over a small DOM, and reads the sheet's motion rules.
@@ -0,0 +1,140 @@
1
+ 'use strict';
2
+
3
+ // modules/lifecycle/autopickup-files.js — an automatic page tweak may change only
4
+ // its own page (task 1004678 / BV2.TW19b, ADR 0371 D7, D8).
5
+ //
6
+ // The Autobongos tweak lane claims a round with the auto_pickup mark (task
7
+ // 1004677) and runs `/tweak --page <id> --unattended` with nobody watching. The
8
+ // skill tells the worker to stay inside the page; this file is why the server
9
+ // does not have to TRUST that. At publish, the route asks `autoPickupFileRefusal`
10
+ // for the task; when the round's latest claim carries the mark, the server reads
11
+ // the pull request's files itself (GitHub, never the client's list) and refuses
12
+ // the publish if any path is
13
+ //
14
+ // - outside the page's own files (docs/page-inventory.json `files` +
15
+ // `states_files`) and not one of the files a ship regenerates anyway, or
16
+ // - a protected path (ADR 0043's matcher, the same one decideTask runs).
17
+ //
18
+ // FAIL CLOSED. A page id that does not parse, a page the inventory does not know,
19
+ // a file read that throws, or a list at GitHub's cap (it stops at 300 and says
20
+ // nothing) all refuse: an unattended round that cannot be checked is a round a
21
+ // person applies. An UNMARKED claim is never read here, so every other publish is
22
+ // unchanged.
23
+
24
+ const fs = require('node:fs');
25
+ const path = require('node:path');
26
+ const api = require('../../src/module-api');
27
+ const githubPush = require('./github-push');
28
+ const { PR_FILES_CAP } = require('./ship-files');
29
+ const { isPageTweak } = require('./page-tweak-hold');
30
+
31
+ const REPO_ROOT = path.resolve(__dirname, '..', '..');
32
+ const INVENTORY_REL = 'docs/page-inventory.json';
33
+
34
+ // What a /tweak ship writes besides the page's own files: copy-apply regenerates
35
+ // the copy registry and re-reads the page, and ship.js refreshes the generated
36
+ // maps. Not CLAUDE.md: it is protected, and its §13 snippet moves only when the
37
+ // branch adds a session-log file, which the unattended mode never writes. A path
38
+ // here still runs through the protected-path matcher.
39
+ const SHIP_WRITES = Object.freeze([
40
+ 'docs/copy-registry.json',
41
+ 'docs/copy-inventory.md',
42
+ 'docs/page-readings.json',
43
+ 'docs/repo-map.md',
44
+ 'docs/file-map.md',
45
+ ]);
46
+
47
+ // 'page-tweak/landing:index/r2' -> 'landing:index' (copy-desk's parseRoundRef,
48
+ // restated: a module never requires another module).
49
+ function pageIdFromRef(sourceRef) {
50
+ const m = /^page-tweak\/([a-z0-9-]+:[a-z0-9-]+)\/r[1-9][0-9]*$/.exec(String(sourceRef || ''));
51
+ return m ? m[1] : null;
52
+ }
53
+
54
+ function readInventory(root = REPO_ROOT) {
55
+ return JSON.parse(fs.readFileSync(path.join(root, INVENTORY_REL), 'utf8'));
56
+ }
57
+
58
+ function pageFiles(inventory, pageId) {
59
+ const page = ((inventory && inventory.pages) || []).find((p) => p && p.id === pageId);
60
+ if (!page) return null;
61
+ return [...(page.files || []), ...(page.states_files || [])];
62
+ }
63
+
64
+ // PURE. The verdict over one round's changed paths: null when every path may
65
+ // change, else { code, page_id, outside[], protected[] }.
66
+ function decideFiles({ pageId, allowed, changed, truncated = false, matchProtected }) {
67
+ if (!pageId || !Array.isArray(allowed)) {
68
+ return { code: 'auto_pickup_page_unknown', page_id: pageId || null, outside: [], protected: [] };
69
+ }
70
+ if (truncated) return { code: 'auto_pickup_files_unreadable', page_id: pageId, outside: [], protected: [] };
71
+ const ok = new Set([...allowed, ...SHIP_WRITES]);
72
+ const outside = changed.filter((p) => !ok.has(p));
73
+ const prot = (matchProtected(changed) || []).map((m) => (m && m.file) || m);
74
+ if (!outside.length && !prot.length) return null;
75
+ return { code: 'auto_pickup_outside_page', page_id: pageId, outside, protected: prot };
76
+ }
77
+
78
+ // The round as the check needs it, in ONE read: its source, its ref, and whether
79
+ // its latest claim (released or not: a publish runs after the ship resolved it)
80
+ // carried the mark. A read that FAILS answers null and the publish goes on as
81
+ // usual: an ordinary publish must not depend on this read, and a worker cannot
82
+ // make the database fail to see its own claim.
83
+ const ROUND_SQL = `
84
+ SELECT t.id, t.source, t.source_ref,
85
+ (SELECT c.auto_pickup FROM claims c WHERE c.task_id = t.id
86
+ ORDER BY c.claimed_at DESC, c.id DESC LIMIT 1) AS auto_pickup
87
+ FROM tasks t WHERE t.id = $1`;
88
+ async function readRound(taskId, exec = api.pool) {
89
+ try {
90
+ const { rows } = await exec.query(ROUND_SQL, [taskId]);
91
+ return rows[0] || null;
92
+ } catch (_) {
93
+ return null;
94
+ }
95
+ }
96
+
97
+ // The route's one call. Returns null (publish as usual) or the refusal.
98
+ // `deps` is the test seam.
99
+ async function autoPickupFileRefusal({ taskId, prNumber }, deps = {}) {
100
+ const round = await (deps.readRound || readRound)(taskId);
101
+ if (!isPageTweak(round) || round.auto_pickup !== true) return null;
102
+ const pageId = pageIdFromRef(round.source_ref);
103
+ let allowed = null;
104
+ try { allowed = pageId ? pageFiles((deps.readInventory || readInventory)(), pageId) : null; } catch (_) { allowed = null; }
105
+ let rows;
106
+ try {
107
+ rows = await (deps.getPullRequestFiles || ((a) => githubPush.getPullRequestFiles(a)))({ number: Number(prNumber) });
108
+ } catch (_) {
109
+ return { code: 'auto_pickup_files_unreadable', page_id: pageId, outside: [], protected: [] };
110
+ }
111
+ const list = Array.isArray(rows) ? rows : [];
112
+ return decideFiles({
113
+ pageId, allowed,
114
+ changed: list.map((x) => x && x.path).filter((p) => typeof p === 'string' && p),
115
+ truncated: list.length >= PR_FILES_CAP,
116
+ matchProtected: deps.matchProtected || ((files) => api.permissionPathCheck.matchProtected(files)),
117
+ });
118
+ }
119
+
120
+ function refusalMessage(r) {
121
+ const parts = [];
122
+ if (r.outside && r.outside.length) parts.push(`outside the page: ${r.outside.slice(0, 10).join(', ')}`);
123
+ if (r.protected && r.protected.length) parts.push(`protected: ${r.protected.slice(0, 10).join(', ')}`);
124
+ const why = {
125
+ auto_pickup_outside_page: `An automatic page tweak may change only its own page's files (${parts.join('; ')}).`,
126
+ auto_pickup_page_unknown: 'An automatic page tweak names a page the inventory does not know, so its files cannot be checked.',
127
+ auto_pickup_files_unreadable: "The pull request's files could not be read in full, so an automatic round cannot be checked.",
128
+ }[r.code];
129
+ return `${why} The pull request was closed; the round waits for a person (ADR 0371 D8).`;
130
+ }
131
+
132
+ module.exports = {
133
+ SHIP_WRITES,
134
+ pageIdFromRef,
135
+ pageFiles,
136
+ decideFiles,
137
+ readRound,
138
+ autoPickupFileRefusal,
139
+ refusalMessage,
140
+ };
@@ -1038,7 +1038,8 @@ async function confirmTask({ taskId, builderId }, deps = {}) {
1038
1038
  // degenerate state, e.g. status set directly via SQL) so a legitimate
1039
1039
  // confirm is never refused. Mirrors the #538 fix on the override path.
1040
1040
  const creditedBuilderId = (await mostRecentClaimHolderId(client, taskId)) ?? builderId;
1041
-
1041
+ const { rows: srcRows } = await client.query('SELECT source FROM tasks WHERE id = $1', [taskId]); // ADR 0371 D8 (task 1004678): never past the artist
1042
+ if (isPageTweak(srcRows[0] || null)) throw Object.assign(new Error('a page tweak is confirmed only by its artist'), { code: 'PAGE_TWEAK_NEEDS_ARTIST' });
1042
1043
  const { rows } = await client.query(
1043
1044
  `UPDATE tasks
1044
1045
  SET status = 'confirmed',
@@ -36,7 +36,7 @@ const { gradeQualityWhere } = require('../db-shared');
36
36
  const { helperJoinSql } = require('../page-tweak-artist'); // task 1004432: a page tweak's applier as a helper
37
37
  const auth = api;
38
38
  const db = require('../db');
39
- const pageTweakReads = require('../page-tweak-reads'); const { HELD_NOT_ARMED, prMergeSentence } = require('../page-tweak-hold'); // ADR 0341 D7 (tests stub the read)
39
+ const pageTweakReads = require('../page-tweak-reads'); const { HELD_NOT_ARMED, prMergeSentence } = require('../page-tweak-hold'); const autoPickupFiles = require('../autopickup-files'); const githubPrClose = require('../github-pr-close'); // ADR 0341 D7 (tests stub the read); ADR 0371 D8
40
40
  const hierarchy = require('../hierarchy');
41
41
  const { suggestCriteria } = require('../criterion-suggest');
42
42
  // The orphan-task advisory (task 1002539) — pure, fail-open; see goal-advisory.js.
@@ -696,9 +696,8 @@ module.exports = function buildTasksRouter() {
696
696
  const result = await db.confirmTask({ taskId: id, builderId: req.builder.id });
697
697
  res.json({ ok: true, ...result });
698
698
  } catch (err) {
699
- if (err.code === 'TASK_NOT_COMPLETED') {
700
- return res.fail('task_not_completed', 409);
701
- }
699
+ if (err.code === 'TASK_NOT_COMPLETED') return res.fail('task_not_completed', 409);
700
+ if (err.code === 'PAGE_TWEAK_NEEDS_ARTIST') return res.fail('page_tweak_needs_artist', { status: 409, message: `Task #${id} is a page tweak: it is confirmed only when its artist approves it in the studio, or by the owner's override request (ADR 0371 D8).` });
702
701
  log.error('[gds] POST /tasks/:id/confirm', err);
703
702
  res.fail('confirm_failed', { status: 500, message: 'internal error' });
704
703
  }
@@ -1184,6 +1183,7 @@ module.exports = function buildTasksRouter() {
1184
1183
  `Merging triggers .github/workflows/deploy-prod.yml -> ${branding().domains.publicOrigin}. ` +
1185
1184
  prMergeSentence(held);
1186
1185
  const pr = await githubPush.openOrFindPullRequest({ branch, title, body });
1186
+ const apRefusal = await autoPickupFiles.autoPickupFileRefusal({ taskId: id, prNumber: pr.number }); if (apRefusal) { await githubPrClose.closePullRequest({ number: pr.number }).catch(() => null); return res.fail(apRefusal.code, 409, { ...apRefusal, message: autoPickupFiles.refusalMessage(apRefusal) }); } // ADR 0371 D8 (task 1004678)
1187
1187
  // Best-effort by contract (pr-assign.js never rejects); the catch is for a future
1188
1188
  // edit that forgets — an assignment must not be able to fail a pushed ship.
1189
1189
  const assignment = await prAssign.assignPullRequest({ number: pr.number, login: builderLogin })
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.21.109",
3
+ "version": "1.21.111",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.21.109",
9
+ "version": "1.21.111",
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.21.109",
3
+ "version": "1.21.111",
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",
@@ -9508,5 +9508,21 @@
9508
9508
  "id": "1004675",
9509
9509
  "text": "Planning sessions now keep the work tied to the design the owner agreed. Each goal's done-when line must carry the agreed model (the named states and rules), and each proof task must describe a scenario a wrong build would fai"
9510
9510
  }
9511
+ ],
9512
+ "1.21.110": [
9513
+ {
9514
+ "id": "1004686",
9515
+ "text": "Artists can now review the hall through anyone's eyes. In Artistry mode, hovering the Review button opens a menu: Ideator, Governing, Building or Artistry experience. Picking one shows every page the way a builder in that mode"
9516
+ },
9517
+ {
9518
+ "id": "1004678",
9519
+ "text": "The page-tweak helper can now run on its own: given one page, it applies the artist's words without asking anything, and if anything goes wrong it hands the page back with a plain note. The server enforces the safety rules its"
9520
+ }
9521
+ ],
9522
+ "1.21.111": [
9523
+ {
9524
+ "id": "1004667",
9525
+ "text": "Lost bug screenshots can now be brought back. We used to tell people a missing screenshot from the Discord bugs channel was gone for good. That was wrong: only the link expires, and the picture stays in Discord as long as the"
9526
+ }
9511
9527
  ]
9512
9528
  }
@@ -2,9 +2,12 @@
2
2
  // scripts/gds/bug-attachment-audit.js — do the images our task descriptions
3
3
  // promise actually exist? (task 1003918)
4
4
  //
5
- // WHY THIS EXISTS. A re-hosted #bugs screenshot is the one artefact in this
6
- // system that cannot be regenerated: the Discord CDN URL it was copied from is
7
- // signed and expires in about a day, so our copy IS the image. They live on disk
5
+ // WHY THIS EXISTS. A re-hosted #bugs screenshot is the hardest artefact in this
6
+ // system to get back: the Discord CDN URL it was copied from carries a signature
7
+ // that expires in about a day. The FILE does not expire — Discord keeps it while
8
+ // the message exists, and bug-attachment-recover.js re-fetches it by the message
9
+ // id the filename carries (task 1004667) — but once that message or the channel
10
+ // history is gone, our copy IS the image. They live on disk
8
11
  // under var/bug-attachments — outside Postgres, outside git — and nothing
9
12
  // noticed when all five this instance had ever stored disappeared in the
10
13
  // 2026-07-05 droplet move. The loss surfaced on 2026-09-14, two months later,
@@ -124,7 +127,8 @@ async function main() {
124
127
  if (argv.includes('--line')) {
125
128
  if (r.missing.length) {
126
129
  console.log(`Bug attachments: ${r.missing.length} of ${r.described.length} linked screenshot(s) are MISSING from ${dir} `
127
- + '— every task describing one renders a broken image. These cannot be re-fetched (the Discord URL has expired).');
130
+ + '— every task describing one renders a broken image. '
131
+ + 'Try node scripts/gds/bug-attachment-recover.js --missing (it re-fetches from the original Discord message while that message still exists).');
128
132
  }
129
133
  return;
130
134
  }
@@ -132,12 +136,14 @@ async function main() {
132
136
  console.log(` Store: ${dir}`);
133
137
  for (const m of r.missing) console.log(` missing ${m.name} (task ${m.taskId})`);
134
138
  if (r.missing.length) {
135
- console.log(' These cannot be re-fetched — the Discord CDN URL they were copied from has expired.');
136
- console.log(' Restore from a nightly attachments archive: /var/backups/gds/<db>-attachments-*.tar.gz');
139
+ console.log(' The stored Discord URL has expired, but only its signature: the message usually still holds the file,');
140
+ console.log(' and each filename carries its message id. Re-fetch them to their original names with:');
141
+ console.log(' node scripts/gds/bug-attachment-recover.js --missing');
142
+ console.log(' If the message is gone, restore from a nightly attachments archive: /var/backups/gds/<db>-attachments-*.tar.gz');
137
143
  process.exit(1);
138
144
  }
139
145
  }
140
146
 
141
147
  if (require.main === module) main();
142
148
 
143
- module.exports = { describedAttachments, auditRows, LINK_RE };
149
+ module.exports = { describedAttachments, auditRows, fetchRows, LINK_RE };