@bongos/core 1.19.664 → 1.19.666

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.
@@ -16,21 +16,35 @@
16
16
  // shows people. Best-effort: an instance without the copy-desk module has
17
17
  // no queue, and the room degrades to reviews alone rather than erroring.
18
18
  //
19
- // It writes NOTHING and gates NOTHING (ADR 0162 / the goal's no-live-CMS rule):
20
- // "open this" routes to where the craft is actually done (the task record, the
21
- // copy desk), and "let it be for now" only advances the reader to the next
22
- // piece. The full tables it replaces stay one link away in the footer.
19
+ // It gates NOTHING (ADR 0162 / ADR 0241): nothing here holds a ship or a deploy.
20
+ // And it writes NOTHING to any live string or asset the goal's no-live-CMS
21
+ // rule, unchanged. What it DOES write, since task 1003829, is the artist's own
22
+ // VERDICT on their own queue: a review is a task in the ledger, and the three
23
+ // weights below move that task the way triage always could. The craft itself
24
+ // still happens on the surface that owns it (the task record, the copy desk),
25
+ // and every change to the product still lands as a claimed, graded, shipped
26
+ // task. The allowlist of what may be written is WRITE_ROUTES below, pinned in
27
+ // tests/hall_studio_world.mjs.
28
+ //
29
+ // THE PICTURE (task 1003829). A review names the shipped task it is about, and
30
+ // that task may carry a ship-time visual. The room reads it for the card in
31
+ // focus and renders the image AS the card, because "look at it" is the whole of
32
+ // what an artist is being asked to do here. One read per card, lazily, so a
33
+ // 50-deep queue still costs one /tasks/:id.
23
34
 
24
35
  (() => {
25
36
  'use strict';
26
37
 
27
38
  const API = '/api/bongos';
28
39
  const api = window.BongosClient.createClient({ baseUrl: '', credentials: 'same-origin', throwOnError: false });
29
- const { escapeHtml, $ } = window.OTB;
40
+ const { escapeHtml, $, toast, errMessage } = window.OTB;
41
+ const kit = window.OTBKit;
30
42
 
31
43
  let viewer = null;
32
44
  let pieces = []; // the combined garden: [{ type:'review'|'flag', … }]
33
45
  let idx = 0; // which stone is in focus
46
+ let busy = false; // a verdict is in flight — one at a time, so a double-click cannot file twice
47
+ let tended = false; // at least one verdict landed this sitting, so an emptied garden reads as finished
34
48
 
35
49
  // Bound the reviews read. cascade.js files a review per qualifying ship and they
36
50
  // sit at backlog until claimed or dismissed ("dismissable rather than nagging"),
@@ -42,6 +56,43 @@
42
56
  // keeps the most-prioritised waiting reviews.
43
57
  const REVIEW_LIMIT = 50;
44
58
 
59
+ // The ONLY writes this room may issue, as an explicit allowlist rather than a
60
+ // convention (tests/hall_studio_world.mjs asserts studio.js writes nothing
61
+ // outside it). Each moves the REVIEW TASK — the artist's own queue item — and
62
+ // none of them touches a live string, an asset, a claim, a grade or a deploy:
63
+ //
64
+ // abandon looks good → the review is dismissed, which cascade.js's own
65
+ // body calls "a real, expected outcome".
66
+ // patch note it → the artist's words are appended to the review's
67
+ // brief, which is what whoever claims it reads.
68
+ // create needs work → the words become a new task in the reviewed
69
+ // work's goal…
70
+ // merge → …and the review is closed against that task, so
71
+ // the follow-up carries the lineage instead of the
72
+ // review just vanishing.
73
+ //
74
+ // All four floor at METIC server-side (modules/government/catalog.js:
75
+ // task.abandon / task.edit / task.create / task.merge). The room therefore
76
+ // OFFERS them only to a viewer who holds that floor — the task 1003751 lesson,
77
+ // that a card must not print a verb its route will refuse. ADR 0016 still
78
+ // stands: this is cosmetic, and the routes are the wall.
79
+ const WRITE_ROUTES = Object.freeze({
80
+ abandon: (reviewId) => `${API}/tasks/${reviewId}/abandon`,
81
+ patch: (reviewId) => `${API}/tasks/${reviewId}`,
82
+ create: () => `${API}/tasks`,
83
+ merge: (reviewId) => `${API}/tasks/${reviewId}/merge`,
84
+ });
85
+
86
+ // The verdict floor, read off /me. Local like goals-render.js's copy — four
87
+ // ranks, one comparison, and no reason for the kit to own a ladder.
88
+ const RANK_ORDER = { archon: 4, metic: 3, thetes: 2, xenos: 1 };
89
+ const MAX_WORDS = 2000; // the abandon/merge `reason` cap the routes apply anyway
90
+
91
+ function canVerdict() {
92
+ const rank = String((viewer && viewer.rank) || '').toLowerCase();
93
+ return (RANK_ORDER[rank] ?? 0) >= RANK_ORDER.metic;
94
+ }
95
+
45
96
  // ---------------------------------------------------------------------------
46
97
  // helpers
47
98
  // ---------------------------------------------------------------------------
@@ -80,18 +131,81 @@
80
131
  // the focus card, one shape per kind of work
81
132
  // ---------------------------------------------------------------------------
82
133
 
134
+ // THE PLATE — the reviewed work's ship-time visual, at the size the card can
135
+ // give it. Through the kit's one renderer (OTBKit.taskVisualFigureHtml, which
136
+ // carries the server-minted-URL guard and the escaping) with the Studio's own
137
+ // class, because the size is this surface's decision: here the picture is the
138
+ // subject, not an illustration beside a record.
139
+ //
140
+ // Draws nothing when there is no visual, when the reviewed task could not be
141
+ // read, or when the kit is absent — a review with no picture must read as a
142
+ // review, never as an empty frame (the degrade rule).
143
+ function plateHtml(p) {
144
+ if (!p.shipped || !kit || typeof kit.taskVisualFigureHtml !== 'function') return '';
145
+ return kit.taskVisualFigureHtml(p.shipped, { className: 'studio-plate' });
146
+ }
147
+
148
+ // The three weights, and the one field they share. Offered only to a viewer
149
+ // whose rank clears the routes' floor (see WRITE_ROUTES) — otherwise the card
150
+ // says plainly where the verdict is recorded instead of printing three
151
+ // buttons that would 403.
152
+ //
153
+ // "looks good" takes words or no words; the other two require them, because a
154
+ // note with no note and a follow-up task with no brief are both worse than
155
+ // nothing. The check is in the handler, and it answers inline.
156
+ function verdictHtml(p) {
157
+ if (!canVerdict()) {
158
+ return `<p class="studio-verdict__sealed">Your verdict on a review is recorded from the review itself — open it to dismiss it, note what you saw, or file the follow-up.</p>`;
159
+ }
160
+ const id = escapeHtml(String(p.taskId));
161
+ return `
162
+ <div class="studio-verdict">
163
+ <label class="studio-verdict__label" for="studio-words">What you saw <small>— your words, in your voice. Needed for a note or a follow-up.</small></label>
164
+ <textarea class="studio-verdict__words" id="studio-words" rows="3" maxlength="${MAX_WORDS}"
165
+ placeholder="The headline lands, but the sub-line reads like a brochure."></textarea>
166
+ <p class="studio-verdict__err" id="studio-verdict-err" hidden></p>
167
+ <!-- Three ghosts, no accent: the weights are three equal answers, and
168
+ the One Warm Thing Rule spends the accent on the door to the craft
169
+ below. Nothing here should push an artist toward filing work. -->
170
+ <div class="studio-verdict__weights">
171
+ <button type="button" class="btn-ghost" data-verdict="good" data-review="${id}">Looks good</button>
172
+ <button type="button" class="btn-ghost" data-verdict="note" data-review="${id}">Note it</button>
173
+ <button type="button" class="btn-ghost" data-verdict="work" data-review="${id}">Needs work</button>
174
+ </div>
175
+ </div>`;
176
+ }
177
+
178
+ // The two steps of the craft (R04) — what happens INSIDE a review once it is
179
+ // opened. Today's copy, and the whole of what a review card could say before
180
+ // there was a picture on it.
181
+ const STEPS = `
182
+ <ol class="studio-steps">
183
+ <li class="studio-step"><span class="studio-step__n">01</span><span>Coach the grader<small>Review how it judged this work, and teach it what good looked like.</small></span></li>
184
+ <li class="studio-step"><span class="studio-step__n">02</span><span>Remake or tweak<small>Only if it needs it — with the AI at your side, or on your own.</small></span></li>
185
+ </ol>`;
186
+
83
187
  function reviewCard(p, n) {
84
188
  const title = p.shippedId
85
189
  ? `The copy and visuals that shipped in task #${escapeHtml(p.shippedId)}`
86
190
  : escapeHtml(p.title || 'A review of what shipped');
191
+ const plate = plateHtml(p);
192
+ // WITH A PICTURE the card is the picture: one line of framing, then the
193
+ // verdict, so the thing being judged and the answer to it are on one screen.
194
+ // The two steps describe the work done AFTER opening the review, and they
195
+ // belong behind that door rather than between the artist and their answer.
196
+ //
197
+ // WITHOUT ONE the card degrades to exactly the copy it has always shown —
198
+ // the framing paragraph and both steps — so a review with no visual still
199
+ // reads as a review and never as an empty frame.
200
+ const body = plate
201
+ ? `<p class="studio-card__context">It shipped and it is live. Say what you see — your review coaches the grader and never blocks what shipped.</p>`
202
+ : `<p class="studio-card__context">It shipped and it is live. Your review coaches the grader on how it judged the work, then remakes or tweaks the words or art only if they need it — it never blocks what shipped.</p>${STEPS}`;
87
203
  return `
88
204
  <p class="studio-card__eyebrow">to tend now · ${escapeHtml(n)}</p>
89
205
  <h2 class="studio-card__title">${title}</h2>
90
- <p class="studio-card__context">It shipped and it is live. Your review coaches the grader on how it judged the work, then remakes or tweaks the words or art only if they need it — it never blocks what shipped.</p>
91
- <ol class="studio-steps">
92
- <li class="studio-step"><span class="studio-step__n">01</span><span>Coach the grader<small>Review how it judged this work, and teach it what good looked like.</small></span></li>
93
- <li class="studio-step"><span class="studio-step__n">02</span><span>Remake or tweak<small>Only if it needs it — with the AI at your side, or on your own.</small></span></li>
94
- </ol>
206
+ ${plate}
207
+ ${body}
208
+ ${verdictHtml(p)}
95
209
  <div class="studio-card__actions">
96
210
  <a class="btn-accent" href="/builders/task/${escapeHtml(String(p.taskId))}">Open this review</a>
97
211
  <button type="button" class="btn-ghost" data-skip>Let it be for now</button>
@@ -139,11 +253,17 @@
139
253
  const focus = $('#studio-focus');
140
254
  const rest = $('#studio-rest');
141
255
 
142
- if (!pieces.length) { focus.innerHTML = emptyCard(false); rest.hidden = true; return; }
256
+ // An emptied garden reads as FINISHED once the reader has actually tended
257
+ // something this sitting, and as quiet when there was nothing to begin with.
258
+ if (!pieces.length) { focus.innerHTML = emptyCard(tended); rest.hidden = true; return; }
143
259
  if (idx >= pieces.length) { focus.innerHTML = emptyCard(true); rest.hidden = true; return; }
144
260
 
145
261
  const p = pieces[idx];
146
262
  focus.innerHTML = p.type === 'review' ? reviewCard(p, `${idx + 1} of ${pieces.length}`) : flagCard(p, `${idx + 1} of ${pieces.length}`);
263
+ // The reviewed work is read for the card in FOCUS, and only once. hydrate()
264
+ // repaints when it lands, so the card appears immediately and the picture
265
+ // arrives a beat later rather than the room waiting on a second request.
266
+ hydrate(p);
147
267
 
148
268
  // The rest of the garden: one stone per remaining piece, quiet and
149
269
  // non-interactive — the room asks the reader to tend one thing, not to scan.
@@ -160,6 +280,190 @@
160
280
  }
161
281
  }
162
282
 
283
+ // ---------------------------------------------------------------------------
284
+ // the reviewed work — read for the card in focus, once
285
+ // ---------------------------------------------------------------------------
286
+
287
+ // Hydrate ONE review with the task it is about, then repaint. Lazy on purpose:
288
+ // the queue is capped at 50 and the room shows one piece at a time, so eagerly
289
+ // reading every reviewed task would be 50 requests to render one card — the
290
+ // unbounded-read shape modules/hall-ui/CLAUDE.md flags. `hydrated` is set
291
+ // whether or not the read succeeded, so a 404 or a 403 is asked once and the
292
+ // card then simply carries no picture.
293
+ async function hydrate(p) {
294
+ if (!p || p.type !== 'review' || p.hydrated || !p.shippedId) return;
295
+ p.hydrated = true;
296
+ const res = await api.request('GET', `${API}/tasks/${encodeURIComponent(p.shippedId)}`);
297
+ if (!res.ok) return;
298
+ const t = (res.data && res.data.task) || null;
299
+ if (!t) return;
300
+ // The security_sensitive narrowing, taken from the server's own audience
301
+ // predicate (modules/lifecycle/task-visuals.js isPubliclyViewable, and the
302
+ // public feed's SQL): a security_sensitive task's image is the one visual
303
+ // the platform does not re-publish, so this room does not re-publish it
304
+ // either. The review still reads as a review — it degrades to the copy.
305
+ //
306
+ // BE CLEAR WHAT THIS IS: a UI choice, not a wall. isPubliclyViewable gates
307
+ // the UNAUTHENTICATED path, so GET /tasks/:id hands the full visual_url and
308
+ // visual_alt to any signed-in builder and the response in this very browser
309
+ // already carries them. Dropping them here keeps a sensitive image off a
310
+ // room that exists for looking at things, and keeps the URL out of the DOM
311
+ // and out of a screenshot of it. It secures nothing on its own, and nothing
312
+ // here should be read as a substitute for the server's gate.
313
+ p.shipped = t.security_sensitive === true ? { id: t.id } : t;
314
+ if (pieces[idx] === p) paint();
315
+ }
316
+
317
+ // ---------------------------------------------------------------------------
318
+ // the verdict — the artist's answer, recorded against the review
319
+ // ---------------------------------------------------------------------------
320
+
321
+ function words() {
322
+ const el = $('#studio-words');
323
+ return el ? String(el.value || '').trim().slice(0, MAX_WORDS) : '';
324
+ }
325
+
326
+ function verdictError(msg) {
327
+ const el = $('#studio-verdict-err');
328
+ if (!el) return;
329
+ el.textContent = msg || '';
330
+ el.hidden = !msg;
331
+ }
332
+
333
+ // The review is done with: drop it from the garden and move to the next stone
334
+ // without renumbering what the reader already passed.
335
+ function retire(p) {
336
+ const at = pieces.indexOf(p);
337
+ if (at === -1) return;
338
+ tended = true;
339
+ pieces.splice(at, 1);
340
+ if (idx > at) idx -= 1;
341
+ paintHead();
342
+ paint();
343
+ window.scrollTo({ top: 0 });
344
+ }
345
+
346
+ // "Looks good" — dismiss. cascade.js's own body says this is a real and
347
+ // expected outcome, so it is a first-class button and not a hidden one. The
348
+ // words are optional here and ride along as the retire reason, which
349
+ // db.abandonTask stores on the task as "Abandoned: <reason>".
350
+ async function verdictGood(p) {
351
+ const said = words();
352
+ const who = (viewer && (viewer.display_name || viewer.github_login)) || 'an artist';
353
+ const reason = `Reviewed in the studio by ${who} — the copy and visuals are good as shipped.${said ? ` ${said}` : ''}`;
354
+ const res = await api.request('POST', WRITE_ROUTES.abandon(p.taskId), { reason });
355
+ if (!res.ok) return verdictError(errMessage(res.data) || 'That could not be recorded. Nothing changed.');
356
+ toast('Dismissed — the copy and visuals are good as shipped.');
357
+ retire(p);
358
+ return undefined;
359
+ }
360
+
361
+ // "Note it" — the artist's words, recorded against the review, which stays
362
+ // open. They are APPENDED to the review's own description because that is the
363
+ // text whoever claims this review actually reads; the route audit-logs every
364
+ // description edit, so the note has a trail as well as a home.
365
+ //
366
+ // THE BASE IS RE-READ, not taken from the queue row, and both halves of that
367
+ // matter. PATCH description REPLACES the field, so appending to a stale or
368
+ // absent base does not add a note — it DELETES the cascade's brief. The list
369
+ // read happens to carry `description` today (db-tasks.js listTasks is
370
+ // SELECT t.*), but that projection is already under pressure from the board's
371
+ // read sizes, and a narrowing there must not silently blank a review body
372
+ // here. The re-read also means a second artist noting on the same review
373
+ // appends after the first instead of overwriting them. No base, no write.
374
+ async function verdictNote(p) {
375
+ const said = words();
376
+ if (!said) return verdictError('Write what you saw first — a note with no note is worse than none.');
377
+ const cur = await api.request('GET', `${API}/tasks/${p.taskId}`);
378
+ const base = cur.ok && cur.data && cur.data.task ? String(cur.data.task.description || '') : null;
379
+ if (base === null) {
380
+ return verdictError('The review could not be re-read just now, so nothing was written — try again in a moment.');
381
+ }
382
+ const who = (viewer && (viewer.display_name || viewer.github_login)) || 'an artist';
383
+ const stamp = new Date().toISOString().slice(0, 10);
384
+ const block = `\n\n---\n\nARTIST NOTE (${stamp}, ${who}, from the studio):\n\n${said}`;
385
+ const res = await api.request('PATCH', WRITE_ROUTES.patch(p.taskId), { description: `${base}${block}` });
386
+ if (!res.ok) return verdictError(errMessage(res.data) || 'That could not be recorded. Nothing changed.');
387
+ p.description = `${base}${block}`;
388
+ toast('Noted on the review — it stays open.');
389
+ const el = $('#studio-words');
390
+ if (el) el.value = '';
391
+ verdictError('');
392
+ return undefined;
393
+ }
394
+
395
+ // "Needs work" — the words become a NEW task in the reviewed work's goal, and
396
+ // the review is closed AGAINST it.
397
+ //
398
+ // No live edit happens here and none can: what this files is an ordinary
399
+ // backlog task that somebody has to claim, build, have graded and ship, which
400
+ // is goal 1000074's hard rule stated as a mechanism rather than a promise.
401
+ //
402
+ // The review's own goal_id/version_id are the reviewed work's — cascade.js
403
+ // property 3: a generated review inherits the source task's goal and version.
404
+ // So the follow-up lands beside the work it is about with no second read.
405
+ //
406
+ // source_ref makes the pair idempotent: POST /tasks keys on (source,
407
+ // source_ref), so a double-click or a retry resolves to the SAME follow-up
408
+ // rather than minting a second one, and the merge below is idempotent when it
409
+ // re-merges into the target already recorded.
410
+ async function verdictWork(p) {
411
+ const said = words();
412
+ if (!said) return verdictError('Write what needs work first — a follow-up with no brief cannot be claimed.');
413
+ const who = (viewer && (viewer.display_name || viewer.github_login)) || 'an artist';
414
+ const about = p.shippedId ? `task ${p.shippedId}` : `review ${p.taskId}`;
415
+ const created = await api.request('POST', WRITE_ROUTES.create(), {
416
+ version_id: p.versionId,
417
+ goal_id: p.goalId ? Number(p.goalId) : undefined,
418
+ title: `Artist verdict: the copy or visuals shipped in ${about} need work`,
419
+ description: [
420
+ `Filed from the studio by ${who}, as the verdict on artist review ${p.taskId} of ${about}.`,
421
+ '',
422
+ 'WHAT THE ARTIST SAW:',
423
+ '',
424
+ said,
425
+ '',
426
+ 'This is ordinary work: claim it, make the change, have it graded and ship it. The studio files the judgement and never edits a live string or asset (goal 1000074\'s rule).',
427
+ ].join('\n'),
428
+ discipline: 'artist',
429
+ source: 'studio-verdict',
430
+ source_ref: `studio-needs-work-on-review-${p.taskId}`,
431
+ });
432
+ if (!created.ok) return verdictError(errMessage(created.data) || 'The follow-up could not be filed. Nothing changed.');
433
+ const newId = created.data && created.data.task && created.data.task.id;
434
+ if (!newId) return verdictError('The follow-up came back without an id, so the review was left open.');
435
+ // Close the review against the follow-up. If this half fails the follow-up
436
+ // still exists and is reported as such — a stranded-but-visible task beats
437
+ // a silent success, and re-running the same verdict is safe.
438
+ const closed = await api.request('POST', WRITE_ROUTES.merge(p.taskId), {
439
+ into_task_id: Number(newId),
440
+ reason: `Artist verdict from the studio: the review's answer is task ${newId}, which carries the work.`,
441
+ });
442
+ if (!closed.ok) {
443
+ return verdictError(`Task ${newId} was filed, but this review could not be closed against it — close it from the review itself.`);
444
+ }
445
+ toast(`Filed as task ${newId} — this review is closed against it.`);
446
+ retire(p);
447
+ return undefined;
448
+ }
449
+
450
+ const VERDICTS = { good: verdictGood, note: verdictNote, work: verdictWork };
451
+
452
+ async function runVerdict(kind) {
453
+ const p = pieces[idx];
454
+ if (busy || !p || p.type !== 'review' || !VERDICTS[kind]) return;
455
+ if (!canVerdict()) return;
456
+ busy = true;
457
+ verdictError('');
458
+ try {
459
+ await VERDICTS[kind](p);
460
+ } catch (_err) {
461
+ verdictError('That could not be recorded. Nothing changed.');
462
+ } finally {
463
+ busy = false;
464
+ }
465
+ }
466
+
163
467
  // ---------------------------------------------------------------------------
164
468
  // load
165
469
  // ---------------------------------------------------------------------------
@@ -174,7 +478,19 @@
174
478
  return tasks
175
479
  .filter((t) => t.source === 'cascade' || /^artist review\b/i.test(t.title || ''))
176
480
  .sort((a, b) => new Date(b.created_at || 0) - new Date(a.created_at || 0))
177
- .map((t) => ({ type: 'review', taskId: t.id, title: t.title, shippedId: reviewShippedId(t) }));
481
+ // goalId/versionId are the REVIEWED work's cascade.js property 3: a
482
+ // generated review inherits its source task's goal and version. They ride
483
+ // here so the "needs work" follow-up needs no second read, and
484
+ // `description` rides here so "note it" appends rather than overwrites.
485
+ .map((t) => ({
486
+ type: 'review',
487
+ taskId: t.id,
488
+ title: t.title,
489
+ description: t.description,
490
+ goalId: t.goal_id,
491
+ versionId: t.version_id,
492
+ shippedId: reviewShippedId(t),
493
+ }));
178
494
  }
179
495
 
180
496
  // The open flags. Best-effort: no copy-desk module → 404 → no flags, and the
@@ -198,7 +514,15 @@
198
514
 
199
515
  document.addEventListener('click', (ev) => {
200
516
  if (ev.target.closest('[data-skip]')) { idx += 1; paint(); window.scrollTo({ top: 0 }); return; }
201
- if (ev.target.closest('[data-restart]')) { idx = 0; paint(); window.scrollTo({ top: 0 }); }
517
+ if (ev.target.closest('[data-restart]')) { idx = 0; paint(); window.scrollTo({ top: 0 }); return; }
518
+ const weight = ev.target.closest('[data-verdict]');
519
+ if (weight) runVerdict(weight.dataset.verdict);
520
+ });
521
+
522
+ // Clear the inline error as soon as the artist edits the field — the design
523
+ // system's rule: an error stands until the input that caused it changes.
524
+ document.addEventListener('input', (ev) => {
525
+ if (ev.target && ev.target.id === 'studio-words') verdictError('');
202
526
  });
203
527
 
204
528
  (async () => {
@@ -1,5 +1,5 @@
1
1
  {
2
- "_": "The Studio (artist mode), R06 of goal 1000074 (task 1003117). A calm room that surfaces the artist's real work — backlog review requests (cascade, R04) and open copy flags (R02) — one piece at a time, never a table. Against the kit's own stub the page renders its EMPTY state (no /tasks or /copy-desk feed), which is a state the floors must hold in; against the hall-preview harness with the studio__reviews / copy-desk__queue fixtures it renders the focus card and the garden's stones.",
2
+ "_": "The Studio (artist mode), R06 of goal 1000074 (task 1003117). A calm room that surfaces the artist's real work — backlog review requests (cascade, R04) and open copy flags (R02) — one piece at a time, never a table. Against the kit's own stub the page renders its EMPTY state (no /tasks or /copy-desk feed), which is a state the floors must hold in. TO SEE IT POPULATED, point the kit at the hall-preview harness instead: `node scripts/hall-preview/server.js --port 4629 --fixture-me` then `render.js --base-in http://builders.localhost:4629 --base-out http://builders.localhost:4629`. That harness answers /tasks from fixtures/tasks.json (whose first row IS a cascade artist review), the reviewed task from fixtures/tasks__detail.json (which carries a visual_url), and the visual itself as a generated stand-in — task 1003829, where the review card's picture became the subject of the card and a broken frame stopped being reviewable. An earlier version of this note named a studio__reviews.json fixture; there is no such file and there never was.",
3
3
  "page": "/builders/studio",
4
4
  "surface": "hall-ui",
5
5
  "stub": {
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.664",
3
+ "version": "1.19.666",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.664",
9
+ "version": "1.19.666",
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.664",
3
+ "version": "1.19.666",
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",
@@ -44,6 +44,9 @@
44
44
  // 0 when a task was selected (a directive was emitted), 1 on a NO-GO / no work.
45
45
 
46
46
  const { execFileSync } = require('node:child_process');
47
+ // basename(cwd) is the worktree name a claim is bound to (task 1003834) — the
48
+ // same value claim.js records via --worktree.
49
+ const path = require('node:path');
47
50
  const { cliClient } = require('./cli-lib');
48
51
  const grader = require('../../modules/grading/grader');
49
52
  // Phase 4.5: reuse the $0 deterministic autonomy precheck (the same gate the
@@ -195,11 +198,58 @@ function diffStatsSince(base, untracked) {
195
198
  };
196
199
  }
197
200
 
201
+ // THE WORKTREE IS THE ANSWER, when there is one (task 1003834).
202
+ //
203
+ // A claim BINDS to a dedicated worktree — one claim per session, one worktree
204
+ // per claim (task 1642, ADR 0097) — and `claim.js --worktree` records that
205
+ // worktree's leaf-folder name on the claim row, which is exactly
206
+ // `path.basename(process.cwd())`. So when this dispatch runs inside a claimed
207
+ // worktree there is no inference to do: that worktree's claim IS the task being
208
+ // worked on. Everything below it is a heuristic; this is a fact.
209
+ //
210
+ // Claims from before the column existed carry a null worktree_name and are
211
+ // simply not matched here — the heuristic still covers them.
212
+ function claimForWorktree(claims, worktreeName) {
213
+ if (!Array.isArray(claims) || !worktreeName) return null;
214
+ return claims.find((c) => c && c.worktree_name && c.worktree_name === worktreeName) || null;
215
+ }
216
+
198
217
  // Pick the active claim whose touches[] best overlap the changed files — the
199
218
  // claim the current diff most plausibly belongs to. With multiple (possibly
200
219
  // stale) claims, blindly taking claims[0] grades the diff against the wrong
201
220
  // task: the Phase 4 dogfood's Narc FAILed because it graded Conductor code
202
221
  // against an unrelated stale offsite-backup claim. Best-overlap fixes that.
222
+ //
223
+ // SPECIFICITY, NOT VOLUME (task 1003834). A raw count of matched files rewards
224
+ // BREADTH, so the more honestly a claim declares its touches[] the more likely
225
+ // it loses. Observed 2026-09-10: reviewing task 1003829's diff, its own claim
226
+ // declared three exact files (`modules/hall-ui/public/studio.{js,html,css}`) and
227
+ // could therefore score at most 3, while a concurrent rename claim declaring
228
+ // `scripts/gds/`, `src/bongos/`, `modules/`, `clients/` matched most of the
229
+ // same diff and won — so the reviewer was briefed on an unrelated task. A
230
+ // pattern like `modules/` covers almost any diff in this repo and must not
231
+ // out-vote an exact path.
232
+ //
233
+ // So each matched file is worth the SPECIFICITY of the pattern that matched it:
234
+ // its path-segment depth, plus a bonus when the pattern is the file itself.
235
+ // Volume still counts — a genuinely broad claim matching fifty files beats a
236
+ // narrow one sharing a single file — but depth breaks the tie the right way.
237
+ function patternWeight(pattern, file) {
238
+ const p = String(pattern || '');
239
+ const depth = p.replace(/\/+$/, '').split('/').filter(Boolean).length;
240
+ return depth + (p === file ? EXACT_MATCH_BONUS : 0);
241
+ }
242
+ const EXACT_MATCH_BONUS = 1;
243
+
244
+ function claimOverlapScore(claim, files) {
245
+ let score = 0;
246
+ for (const f of files) {
247
+ const hit = matchOne(f, (claim && claim.touches) || []);
248
+ if (hit !== null) score += patternWeight(hit, f);
249
+ }
250
+ return score;
251
+ }
252
+
203
253
  function bestMatchingClaim(claims, changedFiles) {
204
254
  if (!Array.isArray(claims) || claims.length === 0) return null;
205
255
  const files = Array.isArray(changedFiles) ? changedFiles : [];
@@ -207,12 +257,29 @@ function bestMatchingClaim(claims, changedFiles) {
207
257
  let bestScore = -1;
208
258
  for (const c of claims) {
209
259
  // Use the canonical matcher; ties keep the earlier claim (strict >).
210
- const score = files.filter((f) => matchOne(f, c.touches || []) !== null).length;
260
+ const score = claimOverlapScore(c, files);
211
261
  if (score > bestScore) { bestScore = score; best = c; }
212
262
  }
213
263
  return best;
214
264
  }
215
265
 
266
+ // The claim this diff belongs to, and HOW that was decided — because the two
267
+ // answers deserve very different trust. 'worktree' is a fact; 'overlap' is a
268
+ // guess, and a guess that must be visible so a reader can discount a review
269
+ // that argues from the brief rather than from the diff.
270
+ //
271
+ // A claim with an EMPTY touches[] scores zero against any diff, which is most
272
+ // tasks in this repo — another reason the worktree signal leads rather than
273
+ // merely breaking ties.
274
+ function attributeClaim(claims, changedFiles, worktreeName) {
275
+ const byWorktree = claimForWorktree(claims, worktreeName);
276
+ if (byWorktree) return { claim: byWorktree, signal: 'worktree', worktree: worktreeName };
277
+ const byOverlap = bestMatchingClaim(claims, changedFiles);
278
+ if (!byOverlap) return { claim: null, signal: 'none', worktree: worktreeName || null };
279
+ const score = claimOverlapScore(byOverlap, Array.isArray(changedFiles) ? changedFiles : []);
280
+ return { claim: byOverlap, signal: score > 0 ? 'overlap' : 'fallback', worktree: worktreeName || null };
281
+ }
282
+
216
283
  // --- task context (best-effort) ---
217
284
  // Workers grade a diff against a task. For a mid-flight review we use the
218
285
  // best-matching active claim's task; otherwise we synthesize a minimal
@@ -224,7 +291,12 @@ async function resolveTaskContext(changedFiles) {
224
291
  if (me.ok) {
225
292
  const claims = me.data.active_claims || (me.data.active_claim ? [me.data.active_claim] : []);
226
293
  if (claims.length > 0) {
227
- const c = bestMatchingClaim(claims, changedFiles);
294
+ // The worktree this dispatch was invoked in decides it when it can
295
+ // (task 1003834); the touches[] heuristic is the fallback, and which
296
+ // one answered is reported to the reader rather than hidden.
297
+ const attribution = attributeClaim(claims, changedFiles, path.basename(process.cwd()));
298
+ const c = attribution.claim;
299
+ if (!c) throw new Error('no claim attributed');
228
300
  // Pull the full description for richer context (best-effort). Coerce
229
301
  // task_id to an integer before interpolating it into the URL path —
230
302
  // it's a trusted internal value, but a guard costs nothing and closes
@@ -243,6 +315,11 @@ async function resolveTaskContext(changedFiles) {
243
315
  description: description || '(no description fetched — mid-flight review; judge the diff on its own merits)',
244
316
  kind: 'unclassified',
245
317
  version_id: c.version_id,
318
+ // How this task was attributed to this diff, carried so the header can
319
+ // say it (task 1003834). 'worktree' is a fact; anything else is a
320
+ // guess the reader should weigh.
321
+ attribution: attribution.signal,
322
+ claim_count: claims.length,
246
323
  };
247
324
  }
248
325
  }
@@ -257,6 +334,22 @@ async function resolveTaskContext(changedFiles) {
257
334
 
258
335
  // --- rendering ---
259
336
 
337
+ // Say how the task context was chosen, but only when saying it changes what a
338
+ // reader should do (task 1003834). Attribution by WORKTREE is certain, and a
339
+ // single claim leaves nothing to confuse, so both stay silent. A guess made
340
+ // while several claims were open is the case that misled a reader before, and
341
+ // it says so out loud.
342
+ function attributionNote(task) {
343
+ if (!task || !task.id) return '';
344
+ const n = Number(task.claim_count) || 0;
345
+ if (task.attribution === 'worktree' || n < 2) return '';
346
+ const how = task.attribution === 'overlap'
347
+ ? 'guessed from touches[] overlap'
348
+ : 'no signal at all — first claim taken';
349
+ return `\n ⚠ attribution: ${how}, across ${n} open claims. This worktree is bound to no claim, so the brief`
350
+ + '\n may belong to different work: weigh findings about the FILES, discount findings about intent.';
351
+ }
352
+
260
353
  function renderWorker(w) {
261
354
  const lines = [];
262
355
  const status = w.error ? `ERROR (${w.error})` : w.verdict.toUpperCase();
@@ -382,7 +475,7 @@ async function main() {
382
475
  const routeRank = grader.routeRankPrePass(files);
383
476
 
384
477
  console.log(`🎼 Conductor dispatch → ${kinds.join(', ')} (model=${model}, ${files.length} changed file(s), base=${base.slice(0, 8)})`);
385
- console.log(` task context: ${task.id ? '#' + task.id + ' ' + task.title : task.title}`);
478
+ console.log(` task context: ${task.id ? '#' + task.id + ' ' + task.title : task.title}${attributionNote(task)}`);
386
479
  if (routeRank) console.log(' + deterministic route-rank check (route file in diff)');
387
480
  console.log('');
388
481
 
@@ -423,4 +516,9 @@ if (require.main === module) {
423
516
  });
424
517
  }
425
518
 
426
- module.exports = { pickSpecialists, bestMatchingClaim, selectAutonomousDispatch, autoDispatch };
519
+ module.exports = {
520
+ pickSpecialists, bestMatchingClaim, selectAutonomousDispatch, autoDispatch,
521
+ // task 1003834 — the attribution layer, exported so its decisions are pinned
522
+ // rather than only observed through a dispatch run.
523
+ claimForWorktree, claimOverlapScore, attributeClaim, attributionNote,
524
+ };
@@ -183,6 +183,17 @@ app.use((req, res, next) => {
183
183
  return;
184
184
  }
185
185
 
186
+ // The ship-time VISUAL is an image, not a fixture (task 1003829). Without
187
+ // this, every surface that renders one — the task record, a ship feed, the
188
+ // Studio's review card, where the picture IS the card — previews as a broken
189
+ // frame, so the one thing being reviewed is the one thing you cannot see.
190
+ // A generated stand-in; see scripts/hall-preview/task-visual.js.
191
+ if (/^task-visuals\/[A-Za-z0-9._-]+$/.test(apiPath) && req.method === 'GET') {
192
+ const png = require('./task-visual').placeholderPng();
193
+ res.set({ 'content-type': 'image/png', 'cache-control': 'no-store' });
194
+ return res.end(png);
195
+ }
196
+
186
197
  if (req.method !== 'GET') {
187
198
  return res.status(501).json({ error: { code: 'preview_readonly', message: 'the preview harness serves fixtures; writes are not wired' } });
188
199
  }