@bongos/core 1.20.33 → 1.20.35

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.
Files changed (39) hide show
  1. package/.bongos-core.json +53 -43
  2. package/.claude/skills/ask-for-help/SKILL.md +5 -3
  3. package/.claude/skills/collab-review/SKILL.md +8 -5
  4. package/clients/bongos-client/index.d.ts +1 -1
  5. package/docs/adr/0042-builder-self-deploy-ci-auto-merge.md +19 -5
  6. package/docs/adr/README.md +1 -1
  7. package/docs/api/openapi.json +3 -2
  8. package/docs/copy-inventory.md +18 -17
  9. package/docs/copy-registry.json +70 -51
  10. package/docs/module-api-changelog.md +5 -1
  11. package/docs/page-readings.json +14 -14
  12. package/docs/recipes/autobongos-windows-host.md +1 -1
  13. package/modules/autonomy/cadence.js +8 -0
  14. package/modules/autonomy/gauge.js +5 -1
  15. package/modules/hall-ui/public/approval-queue.js +1 -1
  16. package/modules/hall-ui/public/collab-lib.js +10 -1
  17. package/modules/hall-ui/public/collab.js +47 -10
  18. package/modules/hall-ui/public/studio.js +7 -7
  19. package/modules/lifecycle/help-requests.js +35 -2
  20. package/modules/lifecycle/migrations/lifecycle_016_help_request_reopen.sql +23 -0
  21. package/modules/lifecycle/routes/help-requests.js +15 -1
  22. package/modules/provisioning/render-standup.js +4 -1
  23. package/modules/public-landing/public/projects.html +16 -7
  24. package/package-lock.json +2 -2
  25. package/package.json +1 -1
  26. package/release-notes.json +23 -0
  27. package/scripts/gds/autobongos-loop.js +37 -1
  28. package/scripts/gds/autobongos-run.js +38 -80
  29. package/src/module-api.js +1 -1
  30. package/tests/autobongos_cadence.mjs +27 -0
  31. package/tests/autobongos_loop.mjs +107 -94
  32. package/tests/collab_page.mjs +27 -1
  33. package/tests/hall_approval_queue.mjs +5 -1
  34. package/tests/hall_client_request_shape.mjs +103 -0
  35. package/tests/hall_studio_home.mjs +7 -1
  36. package/tests/hall_studio_world.mjs +5 -1
  37. package/tests/help_requests.mjs +54 -1
  38. package/tests/projects_hub_app_step.mjs +42 -0
  39. package/tests/provisioning_render_route.mjs +7 -0
@@ -158,10 +158,17 @@
158
158
  // The composer is CLOSED until asked for. A textarea open on every row would
159
159
  // turn a page you read into a page you are being asked to type on, and the
160
160
  // blocks are condensed to three rows precisely so the page stays readable.
161
+ //
162
+ // "Post & resolve" (task 1004458) is the answer and the close in one press,
163
+ // drawn only for someone L.canClose says may close the ask. The asker can
164
+ // reopen it from their archive if the answer did not do it.
161
165
  function threadHtml(req, key, nowMs) {
162
166
  const thread = L.threadOf(req);
163
167
  const id = escapeHtml(String(req.id));
164
168
  const fid = `cb-reply-${escapeHtml(key)}-${id}`;
169
+ const resolve = L.canClose(req, meId, myCrafts)
170
+ ? `<button class="btn-ghost" type="submit" data-reply-send="${id}" data-reply-resolve>Post &amp; resolve</button>`
171
+ : '';
165
172
  const list = thread.length
166
173
  ? `<ul class="cb-thread">${thread.map((r) => replyHtml(r, nowMs)).join('')}</ul>`
167
174
  : '';
@@ -172,6 +179,7 @@
172
179
  <textarea class="cb-reply-input" name="body" rows="3" maxlength="${L.MAX_REPLY}" placeholder="Write a reply…"></textarea>
173
180
  <div class="cb-reply-actions">
174
181
  <button class="btn-accent" type="submit" data-reply-send="${id}">Post reply</button>
182
+ ${resolve}
175
183
  <button class="btn-ghost" type="button" data-reply-cancel="${fid}">Cancel</button>
176
184
  </div>
177
185
  </form>`;
@@ -267,7 +275,7 @@
267
275
  // draw it and the server's own WHERE decides whether it works — so the worst
268
276
  // a wrong answer here can do is offer a button that refuses.
269
277
  const close = L.canClose(req, meId, myCrafts)
270
- ? `<div class="ov-row__actions"><button class="btn-ghost" data-settle="answered" data-id="${escapeHtml(String(req.id))}">Close this ask</button></div>`
278
+ ? `<div class="ov-row__actions"><button class="btn-ghost" data-settle="answered" data-id="${escapeHtml(String(req.id))}">Mark resolved</button></div>`
271
279
  : '';
272
280
  return `<li class="ov-row ov-row--lead cb-row${live}" data-id="${escapeHtml(String(req.id))}"${extra ? ' data-extra hidden' : ''}>
273
281
  ${faceHtml(L.faceOf(req, 'in'))}
@@ -301,7 +309,7 @@
301
309
  </div>
302
310
  <div class="ov-row__right">
303
311
  <div class="ov-row__actions">
304
- <button class="btn-accent" data-settle="answered" data-id="${id}">Someone helped</button>
312
+ <button class="btn-accent" data-settle="answered" data-id="${id}">Mark resolved</button>
305
313
  <button class="btn-ghost" data-settle="withdrawn" data-id="${id}">Withdraw</button>
306
314
  </div>
307
315
  </div>
@@ -338,6 +346,11 @@
338
346
  // and the sentence: 'in' on the asks made OF them (the asker's face), 'out' on
339
347
  // the asks they made (the addressee's). Exactly the pairing forMeRow and
340
348
  // mineRow use, which is why an archived row reads like the open row it was.
349
+ //
350
+ // ONE ACTION, ON ONE SIDE (task 1004458): the asker's own archived asks carry
351
+ // "Reopen", ticket style — whoever closed it, the person who needed the answer
352
+ // has the last word. The server's WHERE holds the rule (asker only); the button
353
+ // is only drawn where it can work.
341
354
  function archiveRow(dir) {
342
355
  return (req, nowMs, extra, key) => {
343
356
  const outcome = L.outcomeLine(req);
@@ -350,11 +363,15 @@
350
363
  // holds the other facts, and the row keeps no right column at all: there
351
364
  // is nothing on an archived row to act on.
352
365
  const fact = outcome ? `<span class="ov-fact cb-outcome">${escapeHtml(outcome)}</span>` : '';
353
- return `<li class="ov-row ov-row--lead cb-row cb-row--archived" data-id="${escapeHtml(String(req.id))}"${extra ? ' data-extra hidden' : ''}>
366
+ const id = escapeHtml(String(req.id));
367
+ const reopen = L.canReopen(req, meId)
368
+ ? `<div class="ov-row__right"><div class="ov-row__actions"><button class="btn-ghost" data-reopen data-id="${id}">Reopen</button></div></div>`
369
+ : '';
370
+ return `<li class="ov-row ov-row--lead cb-row cb-row--archived" data-id="${id}"${extra ? ' data-extra hidden' : ''}>
354
371
  ${faceHtml(L.faceOf(req, dir))}
355
372
  <div class="ov-row__main">${askHtml(req, key, true)}
356
373
  <div class="ov-row__facts">${directionFact(req, dir)}${contextChip(req)}${fact}</div>
357
- </div>
374
+ </div>${reopen}
358
375
  </li>`;
359
376
  };
360
377
  }
@@ -691,7 +708,7 @@
691
708
  if (settling) return;
692
709
  settling = true;
693
710
  try {
694
- const r = await api.request('POST', `/mingle/pairs/${id}/decide`, { decision });
711
+ const r = await api.request('POST', `/mingle/pairs/${id}/decide`, { body: { decision } });
695
712
  if (!r.ok) {
696
713
  toast(window.OTB.errMessage ? window.OTB.errMessage(r.data) : 'Could not answer that introduction.');
697
714
  return;
@@ -709,7 +726,11 @@
709
726
  // Post a reply. One in flight at a time (the `replying` latch), the same guard
710
727
  // `settling` gives the settle actions: a double-submit on an APPEND-ONLY table
711
728
  // writes the answer twice and there is no edit or delete to take it back with.
712
- async function postReply(form) {
729
+ //
730
+ // `resolve` (task 1004458) closes the ask as 'answered' once the reply has
731
+ // landed — never before, so a refused reply cannot leave an ask closed on words
732
+ // nobody can read.
733
+ async function postReply(form, resolve) {
713
734
  if (replying) return;
714
735
  const id = form.getAttribute('data-reply-form');
715
736
  const input = form.querySelector('.cb-reply-input');
@@ -725,13 +746,22 @@
725
746
  const send = form.querySelector('[data-reply-send]');
726
747
  if (send) { send.disabled = true; send.textContent = 'Posting…'; }
727
748
  try {
728
- const r = await api.request('POST', `/help-requests/${id}/replies`, { body: v.body });
749
+ // The reply's field is ALSO called `body`, so it is nested: the client's
750
+ // `body` option is the whole JSON payload. Passing the bare string sent a
751
+ // top-level JSON string, which the server's strict parser refuses as
752
+ // bad_json (task 1004458).
753
+ const r = await api.request('POST', `/help-requests/${id}/replies`, { body: { body: v.body } });
729
754
  if (!r.ok) {
730
755
  toast(window.OTB.errMessage ? window.OTB.errMessage(r.data) : 'Could not post that reply.');
731
756
  return;
732
757
  }
733
758
  if (input) input.value = '';
734
- toast('Replied.');
759
+ if (resolve) {
760
+ const c = await api.helpRequests.patchHelpRequestsId({ id: Number(id), body: { status: 'answered' } });
761
+ toast(c.ok ? 'Replied and resolved.' : 'Replied, but could not resolve it.');
762
+ } else {
763
+ toast('Replied.');
764
+ }
735
765
  await load();
736
766
  } finally {
737
767
  replying = false;
@@ -758,7 +788,7 @@
758
788
  toast(window.OTB.errMessage ? window.OTB.errMessage(r.data) : 'Could not update that request.');
759
789
  return;
760
790
  }
761
- toast(status === 'answered' ? 'Closed.' : 'Withdrawn.');
791
+ toast(status === 'answered' ? 'Resolved.' : status === 'open' ? 'Reopened.' : 'Withdrawn.');
762
792
  await load();
763
793
  } finally {
764
794
  settling = false;
@@ -785,6 +815,12 @@
785
815
  decideMingle(Number(mingleBtn.getAttribute('data-id')), mingleBtn.getAttribute('data-mingle'));
786
816
  return;
787
817
  }
818
+ const reopenBtn = closest('[data-reopen]');
819
+ if (reopenBtn) {
820
+ ev.preventDefault();
821
+ settle(Number(reopenBtn.getAttribute('data-id')), 'open');
822
+ return;
823
+ }
788
824
  const btn = closest('[data-settle]');
789
825
  if (!btn) return;
790
826
  ev.preventDefault();
@@ -799,7 +835,8 @@
799
835
  const form = ev.target && ev.target.closest ? ev.target.closest('[data-reply-form]') : null;
800
836
  if (!form) return;
801
837
  ev.preventDefault();
802
- postReply(form);
838
+ const resolve = !!(ev.submitter && ev.submitter.hasAttribute('data-reply-resolve'));
839
+ postReply(form, resolve);
803
840
  });
804
841
 
805
842
  load().catch(() => {
@@ -386,7 +386,7 @@
386
386
  const said = words();
387
387
  const who = (viewer && (viewer.display_name || viewer.github_login)) || 'an artist';
388
388
  const reason = `Reviewed in the studio by ${who} — the copy and visuals are good as shipped.${said ? ` ${said}` : ''}`;
389
- const res = await api.request('POST', WRITE_ROUTES.abandon(p.taskId), { reason });
389
+ const res = await api.request('POST', WRITE_ROUTES.abandon(p.taskId), { body: { reason } });
390
390
  if (!res.ok) return verdictError(errMessage(res.data) || 'That could not be recorded. Nothing changed.');
391
391
  toast('Dismissed — the copy and visuals are good as shipped.');
392
392
  retire(p);
@@ -417,7 +417,7 @@
417
417
  const who = (viewer && (viewer.display_name || viewer.github_login)) || 'an artist';
418
418
  const stamp = new Date().toISOString().slice(0, 10);
419
419
  const block = `\n\n---\n\nARTIST NOTE (${stamp}, ${who}, from the studio):\n\n${said}`;
420
- const res = await api.request('PATCH', WRITE_ROUTES.patch(p.taskId), { description: `${base}${block}` });
420
+ const res = await api.request('PATCH', WRITE_ROUTES.patch(p.taskId), { body: { description: `${base}${block}` } });
421
421
  if (!res.ok) return verdictError(errMessage(res.data) || 'That could not be recorded. Nothing changed.');
422
422
  p.description = `${base}${block}`;
423
423
  toast('Noted on the review — it stays open.');
@@ -447,7 +447,7 @@
447
447
  if (!said) return verdictError('Write what needs work first — a follow-up with no brief cannot be claimed.');
448
448
  const who = (viewer && (viewer.display_name || viewer.github_login)) || 'an artist';
449
449
  const about = p.shippedId ? `task ${p.shippedId}` : `review ${p.taskId}`;
450
- const created = await api.request('POST', WRITE_ROUTES.create(), {
450
+ const created = await api.request('POST', WRITE_ROUTES.create(), { body: {
451
451
  version_id: p.versionId,
452
452
  goal_id: p.goalId ? Number(p.goalId) : undefined,
453
453
  title: `Artist verdict: the copy or visuals shipped in ${about} need work`,
@@ -463,17 +463,17 @@
463
463
  discipline: 'artist',
464
464
  source: 'studio-verdict',
465
465
  source_ref: `studio-needs-work-on-review-${p.taskId}`,
466
- });
466
+ } });
467
467
  if (!created.ok) return verdictError(errMessage(created.data) || 'The follow-up could not be filed. Nothing changed.');
468
468
  const newId = created.data && created.data.task && created.data.task.id;
469
469
  if (!newId) return verdictError('The follow-up came back without an id, so the review was left open.');
470
470
  // Close the review against the follow-up. If this half fails the follow-up
471
471
  // still exists and is reported as such — a stranded-but-visible task beats
472
472
  // a silent success, and re-running the same verdict is safe.
473
- const closed = await api.request('POST', WRITE_ROUTES.merge(p.taskId), {
473
+ const closed = await api.request('POST', WRITE_ROUTES.merge(p.taskId), { body: {
474
474
  into_task_id: Number(newId),
475
475
  reason: `Artist verdict from the studio: the review's answer is task ${newId}, which carries the work.`,
476
- });
476
+ } });
477
477
  if (!closed.ok) {
478
478
  return verdictError(`Task ${newId} was filed, but this review could not be closed against it — close it from the review itself.`);
479
479
  }
@@ -882,7 +882,7 @@
882
882
  wedit.save = 'saving';
883
883
  wedit.error = '';
884
884
  paintEditing();
885
- wedit.inflight = api.request('PUT', WRITE_ROUTES.draft(STUDIO), body);
885
+ wedit.inflight = api.request('PUT', WRITE_ROUTES.draft(STUDIO), { body });
886
886
  const res = await wedit.inflight;
887
887
  wedit.inflight = null;
888
888
  if (res.ok) {
@@ -48,7 +48,7 @@ async function craftsForBuilder(builderId, deps = {}) {
48
48
  return [...new Set(list.map(craftForDiscipline).filter(Boolean))];
49
49
  }
50
50
 
51
- const COLS = 'id, requested_by, needs_craft, needs_builder_id, what_is_stuck, task_id, goal_id, status, created_at, updated_at, settled_by, settled_reason';
51
+ const COLS = 'id, requested_by, needs_craft, needs_builder_id, what_is_stuck, task_id, goal_id, status, created_at, updated_at, settled_by, settled_reason, reopened_at';
52
52
  // The two list reads also NAME the people on a row — who is asking (the fact
53
53
  // the collab page owes a helper: "who to answer", task 1003471) and who a
54
54
  // by-name ask is addressed to — through two LEFT JOINs on builders. COLS stays
@@ -257,6 +257,11 @@ async function listOpenAll({ limit = MAX_ROWS } = {}, deps = {}) {
257
257
  // it, and inventing one — treating a reply as an answer, say — would close the
258
258
  // clarifying question that was asking for more detail. Those still need a person.
259
259
  //
260
+ // NEVER a REOPENED row (task 1004458). An asker who reopens an ask is saying the
261
+ // shipped task did NOT answer it; closing it again on the next page load because
262
+ // that same task is still shipped would overrule them in under a second. Once
263
+ // reopened, only a person closes it.
264
+ //
260
265
  // IDEMPOTENT, and safe to run on every list read: the WHERE matches only rows
261
266
  // that are still open, so a second run within the same page load finds nothing.
262
267
  async function settleFromLedger(deps = {}) {
@@ -266,6 +271,7 @@ async function settleFromLedger(deps = {}) {
266
271
  SET status = 'answered', settled_reason = 'task_shipped', updated_at = now()
267
272
  WHERE h.status = 'open'
268
273
  AND h.task_id IS NOT NULL
274
+ AND h.reopened_at IS NULL
269
275
  AND EXISTS (SELECT 1 FROM tasks t WHERE t.id = h.task_id AND t.status = 'shipped')
270
276
  RETURNING h.id, h.task_id`
271
277
  );
@@ -359,6 +365,33 @@ async function setHelpRequestStatus({ id, builderId, status, crafts = [] }, deps
359
365
  return rows[0] || null;
360
366
  }
361
367
 
368
+ // THE ASKER REOPENS (task 1004458, owner direction: "like a traditional ticket").
369
+ // Closing moved to the addressee with ADR 0330, which means an ask can now be
370
+ // closed by someone other than the person who needed the answer — so the person
371
+ // who needed it gets the last word. Only they may reopen: an addressee who wants
372
+ // more has a reply box, and a third party reopening someone else's question
373
+ // would be putting it back in a queue its owner had left.
374
+ //
375
+ // Any settled status reopens — 'answered' by a person or by the ledger, and the
376
+ // asker's own 'withdrawn'. The settle fields are cleared so the row reads as the
377
+ // open ask it is again, and `reopened_at` is what keeps settleFromLedger from
378
+ // closing it straight back.
379
+ //
380
+ // Returns null for absent, not yours, or still open — ONE answer for all three,
381
+ // for the same enumeration reason setHelpRequestStatus gives.
382
+ async function reopenHelpRequest({ id, builderId }, deps = {}) {
383
+ const activePool = deps.pool || pool;
384
+ const { rows } = await activePool.query(
385
+ `UPDATE lifecycle_help_requests
386
+ SET status = 'open', settled_by = NULL, settled_reason = NULL,
387
+ reopened_at = now(), updated_at = now()
388
+ WHERE id = $1 AND requested_by = $2 AND status <> 'open'
389
+ RETURNING ${COLS}`,
390
+ [id, builderId]
391
+ );
392
+ return rows[0] || null;
393
+ }
394
+
362
395
  // ---- replies (task 1004216) -------------------------------------------------
363
396
  //
364
397
  // APPEND-ONLY, and the table's own trigger is the authority — there is deliberately
@@ -450,7 +483,7 @@ async function repliesForRequests(ids, deps = {}) {
450
483
  module.exports = {
451
484
  HELP_CRAFTS, helpCrafts, disciplineForCraft, craftForDiscipline, craftsForBuilder,
452
485
  validateHelpRequest, MAX_STUCK, MAX_ROWS, capRows,
453
- builderExists, createHelpRequest, listOpenForBuilder, listMine, listOpenAll, getHelpRequest, setHelpRequestStatus,
486
+ builderExists, createHelpRequest, listOpenForBuilder, listMine, listOpenAll, getHelpRequest, setHelpRequestStatus, reopenHelpRequest,
454
487
  settleFromLedger, listArchivedForBuilder, listArchivedMine,
455
488
  countOpenForBuilder, craftMapSql,
456
489
  MAX_REPLY, MAX_THREAD_ON_LIST, validateReply, createReply, listReplies, repliesForRequests,
@@ -0,0 +1,23 @@
1
+ -- lifecycle_016_help_request_reopen.sql — the asker can reopen a settled ask
2
+ -- (task 1004458, owner direction 2026-09-30: "mark it as resolved and the
3
+ -- requester should then be able to re-open like traditional ticket fashion").
4
+ --
5
+ -- WHY A COLUMN AND NOT JUST A STATUS FLIP. Reopening sets status back to 'open',
6
+ -- which is all the page needs. But settleFromLedger (lifecycle_012) closes every
7
+ -- open ask whose task has shipped, on every Collab read — so an ask the ledger
8
+ -- closed, then reopened by its asker, would be closed again on the very next
9
+ -- page load. `reopened_at` is the fact that settle reads to leave it alone: the
10
+ -- asker has said the shipped task did not answer the question, and only a person
11
+ -- closes it from then on.
12
+ --
13
+ -- MODULE-OWNED and `lifecycle_`-prefixed, so fitness check 11 permits it; no core
14
+ -- table is altered (ADR 0083 §Decision #5).
15
+
16
+ ALTER TABLE lifecycle_help_requests
17
+ ADD COLUMN IF NOT EXISTS reopened_at timestamptz;
18
+
19
+ COMMENT ON COLUMN lifecycle_help_requests.reopened_at IS
20
+ 'When the asker last reopened this ask after it was settled (task 1004458). Non-NULL keeps settleFromLedger from closing it again because its task shipped.';
21
+
22
+ INSERT INTO schema_migrations (version) VALUES ('lifecycle_016_help_request_reopen')
23
+ ON CONFLICT (version) DO NOTHING;
@@ -200,6 +200,9 @@ module.exports = function buildHelpRequestsRouter() {
200
200
  // Nobody is assigned by being asked — closing an ask ends a question, it does
201
201
  // not accept work (ADR 0187 §3).
202
202
  //
203
+ // 'open' REOPENS a settled ask (task 1004458) — the asker's alone, ticket
204
+ // style: whoever closed it, the person who needed the answer has the last word.
205
+ //
203
206
  // rank: any-builder — who may settle is enforced in the UPDATE's WHERE, not by
204
207
  // rank. The caller's crafts come from THEIR OWN profile, never from the body,
205
208
  // for the same reason the addressed read refuses a ?craft= it was handed.
@@ -207,9 +210,20 @@ module.exports = function buildHelpRequestsRouter() {
207
210
  const id = parseId(req, res, { code: 'bad_help_request_id', positiveInt: true });
208
211
  if (id === null) return;
209
212
  if (validateOrRespond(req, res, {
210
- status: { required: true, type: 'string', enum: ['answered', 'withdrawn'] },
213
+ status: { required: true, type: 'string', enum: ['answered', 'withdrawn', 'open'] },
211
214
  })) return;
212
215
 
216
+ if (req.body.status === 'open') {
217
+ const reopened = await help.reopenHelpRequest({ id, builderId: req.builder.id });
218
+ if (!reopened) {
219
+ // Same single refusal as below, for the same enumeration reason.
220
+ return res.fail('help_request_not_reopenable', 404, {
221
+ reason: 'no settled help request with that id that you asked',
222
+ });
223
+ }
224
+ return res.json({ ok: true, help_request: reopened });
225
+ }
226
+
213
227
  const crafts = await help.craftsForBuilder(req.builder.id);
214
228
  const row = await help.setHelpRequestStatus({
215
229
  id, builderId: req.builder.id, status: req.body.status, crafts,
@@ -21,7 +21,10 @@
21
21
 
22
22
  // The plan and region vocabulary — shared with the runner's payload builder, which
23
23
  // requires it from here, so the web tier and the runner can never disagree on a name.
24
- const WEB_PLANS = ['starter', 'standard', 'pro', 'pro_plus', 'pro_max', 'pro_ultra'];
24
+ // `free` is a web plan only (task 1004457, owner call 2026-09-30): the app sleeps when
25
+ // idle, which is the owner's trade to make. The database has no free plan here —
26
+ // Render's free Postgres has no backups and expires.
27
+ const WEB_PLANS = ['free', 'starter','standard', 'pro', 'pro_plus', 'pro_max', 'pro_ultra'];
25
28
  const DB_PLANS = ['basic_256mb', 'basic_1gb', 'basic_4gb', 'pro_4gb', 'pro_8gb'];
26
29
  const REGIONS = ['virginia', 'ohio', 'oregon', 'frankfurt', 'singapore'];
27
30
  const MODES = ['adopt', 'greenfield'];
@@ -1434,10 +1434,10 @@ summary{min-height:24px;padding:3px 0;}
1434
1434
  </select>
1435
1435
  </label>
1436
1436
  <label class="wizField" id="renderBuildWrap"><span>Build command <small>— leave empty if there is none</small></span>
1437
- <input type="text" id="f-renderBuild" autocomplete="off" spellcheck="false" maxlength="500" placeholder="npm ci">
1437
+ <input type="text" id="f-renderBuild" autocomplete="off" spellcheck="false" maxlength="500" placeholder="for example: npm ci">
1438
1438
  </label>
1439
1439
  <label class="wizField" id="renderStartWrap"><span>Start command</span>
1440
- <input type="text" id="f-renderStart" autocomplete="off" spellcheck="false" maxlength="500" placeholder="npm start">
1440
+ <input type="text" id="f-renderStart" autocomplete="off" spellcheck="false" maxlength="500" placeholder="for example: npm start">
1441
1441
  </label>
1442
1442
  <label class="wizField"><span>Plan</span>
1443
1443
  <select id="f-renderPlan"></select>
@@ -1800,14 +1800,18 @@ summary{min-height:24px;padding:3px 0;}
1800
1800
  a key reaches every workspace its user is in, so nothing is pre-picked (ADR 0327).
1801
1801
  Prices are Render's list prices per month: starter and basic_256mb were measured by the
1802
1802
  pilot (task 1004142); Render's own pricing page is the source of truth, and the review
1803
- says Render bills the owner. Lists are built node by node, never from an HTML string. */
1804
- var RENDER_WEB_PLANS = [['starter', 'Starter', 7], ['standard', 'Standard', 25], ['pro', 'Pro', 85]];
1803
+ says Render bills the owner. Lists are built node by node, never from an HTML string.
1804
+ Free (task 1004457) is listed first by price but never preselected: its app sleeps when
1805
+ idle, and the list says so rather than letting $0 read as a plain bargain. */
1806
+ var RENDER_WEB_PLANS = [['free', 'Free', 0], ['starter', 'Starter', 7], ['standard', 'Standard', 25], ['pro', 'Pro', 85]];
1807
+ var RENDER_FREE_NOTE = '$0, but it sleeps when idle and takes about a minute to wake up';
1808
+ function planPrice(p) { return p[2] ? 'about $' + p[2] + '/month' : RENDER_FREE_NOTE; }
1805
1809
  var RENDER_DB_PLANS = [['basic_256mb', 'Basic 256 MB', 6], ['basic_1gb', 'Basic 1 GB', 19], ['basic_4gb', 'Basic 4 GB', 75]];
1806
1810
  var RENDER_RUNTIMES = { node: 'Node.js', python: 'Python', ruby: 'Ruby', go: 'Go', rust: 'Rust', elixir: 'Elixir', docker: 'Docker' };
1807
1811
  var RENDER_KEY_RE = /^rnd_[A-Za-z0-9]{16,128}$/;
1808
1812
  function planLabel(list, v) {
1809
1813
  var p = list.filter(function (x) { return x[0] === v; })[0] || list[0];
1810
- return p[1] + ', about $' + p[2] + '/month';
1814
+ return p[1] + ', ' + planPrice(p);
1811
1815
  }
1812
1816
  function runtimeLabel(v) { return RENDER_RUNTIMES[v] || RENDER_RUNTIMES.node; }
1813
1817
  function repoOf(url) { var m = String(url || '').replace(/\.git$/, '').match(/github\.com\/([^/]+\/[^/]+)$/i); return m ? m[1].toLowerCase() : ''; }
@@ -1832,7 +1836,7 @@ summary{min-height:24px;padding:3px 0;}
1832
1836
  function fillPlans(sel, list, v) {
1833
1837
  sel.textContent = '';
1834
1838
  list.forEach(function (p) {
1835
- var o = appEl('option', null, p[1] + ' — about $' + p[2] + '/month');
1839
+ var o = appEl('option', null, p[1] + ' — ' + planPrice(p));
1836
1840
  o.value = p[0];
1837
1841
  if (p[0] === v) o.selected = true;
1838
1842
  sel.appendChild(o);
@@ -1972,7 +1976,12 @@ summary{min-height:24px;padding:3px 0;}
1972
1976
  });
1973
1977
  q('renderNew').addEventListener('input', function (ev) {
1974
1978
  if (ev.target.id === q('f-renderBuild').id) st.renderBuild = ev.target.value;
1975
- if (ev.target.id === q('f-renderStart').id) st.renderStart = ev.target.value;
1979
+ if (ev.target.id === q('f-renderStart').id) {
1980
+ st.renderStart = ev.target.value;
1981
+ // the "say how your app starts" ask is answered the moment there is a command —
1982
+ // it used to stay on screen until the next press of Next
1983
+ if (String(st.renderStart).trim() && /\berr\b/.test(q('h-render').className)) hint('h-render', '');
1984
+ }
1976
1985
  hooks.save();
1977
1986
  });
1978
1987
  function validate() {
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.20.33",
3
+ "version": "1.20.35",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.20.33",
9
+ "version": "1.20.35",
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.20.33",
3
+ "version": "1.20.35",
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",
@@ -8266,6 +8266,10 @@
8266
8266
  }
8267
8267
  ],
8268
8268
  "1.20.33": [
8269
+ {
8270
+ "id": "1004456",
8271
+ "text": "The self-deploy decision record no longer promises a CI re-grade check that was never built; it now records the owner's choice to retire it."
8272
+ },
8269
8273
  {
8270
8274
  "id": "1004396",
8271
8275
  "text": "The unattended runner no longer gets stuck retrying a task it isn't allowed to take: it moves on to the next one, and only waits when a stuck ship is blocking everyone, resuming by itself once that lands."
@@ -8274,5 +8278,24 @@
8274
8278
  "id": "1004449",
8275
8279
  "text": "When a project update fails in a way the system can't fix by itself, it now opens one blocker for a person and posts one message on Discord, instead of failing quietly. When a later update succeeds, the blocker closes by itsel"
8276
8280
  }
8281
+ ],
8282
+ "1.20.34": [
8283
+ {
8284
+ "id": "1004454",
8285
+ "text": "The unattended runner no longer stops after two tasks: it keeps working while there's usage left, and if it hits the usage limit it sleeps until the limit resets and then carries on by itself."
8286
+ }
8287
+ ],
8288
+ "1.20.35": [
8289
+ {
8290
+ "id": "1004461",
8291
+ "text": "Fixes a broken automatic check that was blocking everyone's work from landing."
8292
+ },
8293
+ {
8294
+ "text": "Replying to an ask on the Collab page works again, and asks can now be marked resolved and reopened by the person who asked, like a ticket."
8295
+ },
8296
+ {
8297
+ "id": "1004457",
8298
+ "text": "The project setup wizard now offers Render's Free plan for your app, with a plain note that a free app sleeps when idle and takes about a minute to wake. Starter stays the default. The build and start command boxes no longer l"
8299
+ }
8277
8300
  ]
8278
8301
  }
@@ -164,7 +164,43 @@ function classifyRun({ envelope = null, exitCode = 0, timedOut = false, spawnErr
164
164
  return { outcome: 'claims_shipped', reason: parsed.verdict.what_happened, releaseClaim: false, verdict: parsed.verdict, costUsd: parsed.costUsd };
165
165
  }
166
166
 
167
+ // usageLimitHit — was this worker cut off by the subscription's usage limit?
168
+ // (task 1004454)
169
+ //
170
+ // Owner ruling, 2026-09-30: no fixed cap on work; hitting the limit is fine, and
171
+ // the runner restarts once it resets. That makes the limit message the stop
172
+ // signal, so it has to be recognised — otherwise a limit-hit worker (seconds
173
+ // long, nothing spent) reads as workerNeverRan() and escalates the runner into
174
+ // probing for a condition the owner accepts.
175
+ //
176
+ // Only an ERROR envelope, or a run with no parseable envelope, can be a limit
177
+ // hit. A worker that FINISHED may well write about limits in its verdict (it
178
+ // might have fixed a rate-limit bug), and sleeping on that would stop the runner
179
+ // for doing its job.
180
+ //
181
+ // The CLI's machine form is `Claude AI usage limit reached|<epoch seconds>`; the
182
+ // human forms ("You've hit your limit · resets 3am", "5-hour limit reached ∙
183
+ // resets 3pm") carry a clock time in a zone we would have to guess, so they
184
+ // return resetAt: null and the caller picks a bounded wait instead of parsing one.
185
+ const LIMIT_EPOCH_RE = /usage limit reached\|(\d{9,11})/i;
186
+ const LIMIT_TEXT_RE = /usage limit reached|hit your (usage )?limit|\b(5|five)-hour limit reached|weekly limit reached|limit reached\s*[∙·•|-]\s*resets/i;
187
+ function usageLimitHit({ envelope = null, stderr = '' } = {}) {
188
+ let text = '';
189
+ let parsedEnvelope = null;
190
+ try { parsedEnvelope = typeof envelope === 'string' ? (envelope.trim() ? JSON.parse(envelope) : null) : envelope; } catch (_) { parsedEnvelope = null; }
191
+ if (parsedEnvelope && typeof parsedEnvelope === 'object') {
192
+ if (parsedEnvelope.is_error !== true) return null;
193
+ text = String(parsedEnvelope.result || '');
194
+ } else if (typeof envelope === 'string') {
195
+ text = envelope;
196
+ }
197
+ text = `${text}\n${stderr || ''}`;
198
+ const m = LIMIT_EPOCH_RE.exec(text);
199
+ if (m) return { resetAt: Number(m[1]) };
200
+ return LIMIT_TEXT_RE.test(text) ? { resetAt: null } : null;
201
+ }
202
+
167
203
  module.exports = {
168
204
  WORKER_VERDICT_SCHEMA, DEFAULT_MODEL, DEFAULT_WORKER_TIMEOUT_MS,
169
- buildWorkerArgs, buildWorkerPrompt, parseWorkerEnvelope, classifyRun,
205
+ buildWorkerArgs, buildWorkerPrompt, parseWorkerEnvelope, classifyRun, usageLimitHit,
170
206
  };