@bongos/core 1.21.83 → 1.21.85

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 (36) hide show
  1. package/.bongos-core.json +50 -40
  2. package/clients/bongos-client/index.d.ts +1 -1
  3. package/docs/adr/0369-ship-mode-decides-who-starts-a-ship-not-how-it-lands.md +1 -0
  4. package/docs/api/openapi.json +7 -0
  5. package/docs/api-reference.md +1 -1
  6. package/docs/copy-inventory.md +32 -28
  7. package/docs/copy-registry.json +70 -34
  8. package/docs/module-api-changelog.md +4 -0
  9. package/docs/page-inventory.json +2 -1
  10. package/docs/page-readings.json +121 -113
  11. package/modules/lifecycle/page-tweak-hold.js +12 -0
  12. package/modules/lifecycle/routes/tasks.js +4 -3
  13. package/modules/lifecycle/ship-card.js +18 -1
  14. package/modules/provisioning/birth.js +28 -1
  15. package/modules/provisioning/routes/provisioning.js +3 -1
  16. package/modules/public-landing/public/projects.html +46 -5
  17. package/modules/public-landing/public/projects.states.json +1 -0
  18. package/package-lock.json +2 -2
  19. package/package.json +1 -1
  20. package/release-notes.json +12 -0
  21. package/scripts/gds/ship-finish.js +4 -2
  22. package/scripts/gds/ship-flow.js +4 -2
  23. package/scripts/gds/ship-grade-steps.js +7 -2
  24. package/scripts/gds/ship-land.js +2 -2
  25. package/src/bongos/project-settings.js +16 -2
  26. package/src/branding.js +3 -0
  27. package/src/module-api.js +1 -1
  28. package/tests/held_tweak_reporting.mjs +164 -0
  29. package/tests/page_tweak_hold.mjs +1 -2
  30. package/tests/projects_hub.mjs +1 -1
  31. package/tests/projects_hub_app_step.mjs +1 -1
  32. package/tests/provision_settings_apply.mjs +4 -1
  33. package/tests/provisioning_settings_env.mjs +3 -1
  34. package/tests/wizard_physics_more.mjs +5 -5
  35. package/tests/wizard_ship_mode.mjs +187 -0
  36. package/tests/wizard_six_screens.mjs +3 -2
@@ -21,6 +21,11 @@
21
21
  // that came and went would leave a stale line behind.
22
22
 
23
23
  const BIRTH_KEY = 'founding_birth';
24
+ // The wizard's ship-mode answer (task 1004629): auto | manual, stamped beside the birth on
25
+ // the row the create route just made. Reserved like the birth (out of SETTINGS_VOCAB): the
26
+ // instance's own project_settings['ship.mode'] is where the setting LIVES and where the
27
+ // owner changes it later; this is only what the project was born with.
28
+ const SHIP_MODE_KEY = 'ship_mode';
24
29
  const ISO = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?Z$/;
25
30
 
26
31
  const storedOf = (row) => (row && row.settings && typeof row.settings === 'object' && !Array.isArray(row.settings) ? row.settings : {});
@@ -43,11 +48,18 @@ function demoBox(row) {
43
48
  return { people, hours_each: hours };
44
49
  }
45
50
 
51
+ // The closed ship-mode list is the core's own (src/bongos/project-settings.js), never retyped here.
52
+ const parseShipMode = (raw) => require('../../src/module-api').projectSettings.parseShipMode(raw);
53
+
54
+ // bornShipMode(row) — PURE. The ship mode the wizard asked for, or '' for none (or junk).
55
+ const bornShipMode = (row) => parseShipMode(storedOf(row)[SHIP_MODE_KEY]).mode || '';
56
+
46
57
  // The companion env (SETTINGS_COMPANION_ENV). src/branding.js maps them onto founding.*.
47
58
  const BIRTH_COMPANION_ENV = Object.freeze({
48
59
  FOUNDING_BIRTH: bornAt,
49
60
  DEMO_PEOPLE: (row) => { const d = demoBox(row); return d ? String(d.people) : ''; },
50
61
  DEMO_HOURS: (row) => { const d = demoBox(row); return d ? String(d.hours_each) : ''; },
62
+ SHIP_MODE: bornShipMode,
51
63
  });
52
64
 
53
65
  // stampBirth(db, instanceId, now) — the create route's one write, on the row it just
@@ -61,4 +73,19 @@ async function stampBirth(db, instanceId, now = new Date()) {
61
73
  );
62
74
  }
63
75
 
64
- module.exports = { BIRTH_KEY, BIRTH_COMPANION_ENV, stampBirth };
76
+ // stampShipMode(db, instanceId, raw) — the wizard's ship-mode answer, on the row the create
77
+ // route just made. An absent or unusable answer writes nothing, so the instance reads "never
78
+ // chosen" and asks at its first ship (task 1004630). Write-once, like the birth.
79
+ async function stampShipMode(db, instanceId, raw) {
80
+ const { mode } = parseShipMode(raw);
81
+ if (!mode) return false;
82
+ await db.query(
83
+ `UPDATE provisioning_instances
84
+ SET settings = COALESCE(settings, '{}'::jsonb) || jsonb_build_object($2::text, $3::text)
85
+ WHERE id = $1 AND NOT (COALESCE(settings, '{}'::jsonb) ? $2::text)`,
86
+ [instanceId, SHIP_MODE_KEY, mode],
87
+ );
88
+ return true;
89
+ }
90
+
91
+ module.exports = { BIRTH_KEY, BIRTH_COMPANION_ENV, SHIP_MODE_KEY, stampBirth, stampShipMode };
@@ -359,7 +359,7 @@ module.exports = function provisioningRoutes() {
359
359
  // exactly the set the panel preselected. An explicit [] is the different
360
360
  // answer "none of them". maxItems is a sanity ceiling well above a catalog
361
361
  // that is three modules wide today.
362
- modules: { type: 'array', itemsType: 'string', maxItems: 50 }, terms_acknowledged: { type: 'boolean' }, demo: { type: 'object' }, // the wizard's terms tick (task 1004417, ../terms-ack.js); the demo route's { people, hours_each } (task 1004420, ../demo.js)
362
+ modules: { type: 'array', itemsType: 'string', maxItems: 50 }, terms_acknowledged: { type: 'boolean' }, demo: { type: 'object' }, ship_mode: { type: 'string', enum: ['auto', 'manual'] }, // the wizard's terms tick (task 1004417, ../terms-ack.js); the demo route's { people, hours_each } (task 1004420, ../demo.js)
363
363
  })) return;
364
364
  const body = req.body || {};
365
365
  const detailFault = badProjectDetail(body.detail);
@@ -475,6 +475,8 @@ module.exports = function provisioningRoutes() {
475
475
  // provision intent exists, so the runner's web.env already carries the marker and the demo's box.
476
476
  // Best-effort: a project that misses its marker opens as an ordinary hall; the create still stands.
477
477
  if (created) await birth.stampBirth(pool, instance.id).catch((err) => log.error('[provisioning] birth marker not stamped', err));
478
+ // The wizard's ship mode (task 1004629) rides beside it; a miss means the project asks at its first ship.
479
+ if (created) await birth.stampShipMode(pool, instance.id, body.ship_mode).catch((err) => log.error('[provisioning] ship mode not stamped', err));
478
480
  const demoRow = created && demoAsk.demo ? await demo.startDemoProject(pool, provisioning, instance.id, demoAsk.demo) : null;
479
481
  // Enqueue the provision intent (idempotent: an already-open intent is returned)
480
482
  // — but ONLY when the instance actually needs standing up. Blindly enqueueing on
@@ -1950,6 +1950,31 @@ summary{min-height:24px;padding:3px 0;}
1950
1950
  </div>
1951
1951
  <span class="acts"><button type="button" class="rowChg" data-row-chg aria-expanded="false">Edit</button></span>
1952
1952
  </div>
1953
+
1954
+ <!-- how finished work ships (task 1004629, ADR 0369): Auto unless the owner
1955
+ says otherwise. It rides the create as ship_mode and becomes the project's
1956
+ own ship mode, which its settings page can change any time. -->
1957
+ <div class="setRow" data-row="ship">
1958
+ <span class="k">Shipping</span>
1959
+ <div class="v"><span class="rowV" data-row-v></span><small class="rowS" data-row-s></small>
1960
+ <div class="opts" hidden>
1961
+ <fieldset class="appOpts" id="shipOpts">
1962
+ <legend>When the AI finishes a task</legend>
1963
+ <label class="appOpt">
1964
+ <input type="radio" name="shipMode" value="auto" checked>
1965
+ <span><b>Auto (recommended)</b>
1966
+ <span>The AI ships finished work on its own. You don’t run any commands.</span></span>
1967
+ </label>
1968
+ <label class="appOpt">
1969
+ <input type="radio" name="shipMode" value="manual">
1970
+ <span><b>Manual</b>
1971
+ <span>The AI stops before each ship and waits for you to approve it.</span></span>
1972
+ </label>
1973
+ </fieldset>
1974
+ </div>
1975
+ </div>
1976
+ <span class="acts"><button type="button" class="rowChg" data-row-chg aria-expanded="false">Edit</button></span>
1977
+ </div>
1953
1978
  </div>
1954
1979
  <div class="hint" id="h-review">&nbsp;</div>
1955
1980
  <div class="nav">
@@ -4476,7 +4501,7 @@ summary{min-height:24px;padding:3px 0;}
4476
4501
  request omits the field), [] = the owner switched them all off, [...] =
4477
4502
  what they picked. Never seed it with the preselect — that would store an
4478
4503
  answer nobody gave. */
4479
- var state = { step: 0, name: '', projType: null, description: '', teamShape: null, modules: null, mode: 'greenfield', owner: '', ownerTouched: false, repo: '', repoTouched: false, slug: '', slugTouched: false, domain: '', noAddress: false, appPlatform: 'later', physics: null, demo: null, look: null, termsAck: false, confirmed: {} };
4504
+ var state = { step: 0, name: '', projType: null, description: '', teamShape: null, modules: null, mode: 'greenfield', owner: '', ownerTouched: false, repo: '', repoTouched: false, slug: '', slugTouched: false, domain: '', noAddress: false, appPlatform: 'later', shipMode: 'auto', physics: null, demo: null, look: null, termsAck: false, confirmed: {} };
4480
4505
  try { Object.assign(state, JSON.parse(sessionStorage.getItem(DRAFT_KEY) || '{}')); } catch (e) {}
4481
4506
  var authKnown = false; /* flips true when the boot's /me settles, either way */
4482
4507
 
@@ -5956,7 +5981,6 @@ summary{min-height:24px;padding:3px 0;}
5956
5981
  if (appOptsEl) appOptsEl.addEventListener('change', function (ev) {
5957
5982
  if (ev.target && ev.target.name === 'appPlatform' && !ev.target.disabled) { state.appPlatform = ev.target.value; saveDraft(); syncRenderSetup(); paintSetup(); }
5958
5983
  });
5959
-
5960
5984
  /* ── Render (task 1004352, ADR 0348) ──
5961
5985
  The flow itself is renderConnect(), shared with an existing project's page (task
5962
5986
  1004184). Here it reads and writes the draft (`state`), so every answer but the key
@@ -6073,7 +6097,14 @@ summary{min-height:24px;padding:3px 0;}
6073
6097
  return 'Workspace ' + ws + ' — a new app (' + runtimeLabel(state.renderRuntime || 'node') + ', ' + plan + ')' + db + '. Render bills you directly.';
6074
6098
  }
6075
6099
 
6076
- var ROWS = ['code', 'address', 'app', 'extras', 'team', 'rewards', 'run'];
6100
+ /* the ship row (task 1004629): anything but 'manual' — an older draft has none — is Auto */
6101
+ function shipModeChoice() { return state.shipMode === 'manual' ? 'manual' : 'auto'; }
6102
+ function syncShipStep() {
6103
+ var pick = shipModeChoice();
6104
+ root.querySelectorAll('#shipOpts input[name=shipMode]').forEach(function (i) { i.checked = i.value === pick; });
6105
+ }
6106
+
6107
+ var ROWS = ['code', 'address', 'app', 'extras', 'team', 'rewards', 'run', 'ship'];
6077
6108
  function rowEl(key) { return root.querySelector('#reviewRows .setRow[data-row="' + key + '"]'); }
6078
6109
  function confirmedRows() {
6079
6110
  if (!state.confirmed || typeof state.confirmed !== 'object') state.confirmed = {};
@@ -6118,6 +6149,7 @@ summary{min-height:24px;padding:3px 0;}
6118
6149
  if (key === 'extras') renderModules();
6119
6150
  if (key === 'code') syncHome();
6120
6151
  if (key === 'app') { syncAppStep(); syncRenderSetup(); }
6152
+ if (key === 'ship') syncShipStep();
6121
6153
  }
6122
6154
  function closeRow(key) {
6123
6155
  var row = rowEl(key);
@@ -6182,6 +6214,8 @@ summary{min-height:24px;padding:3px 0;}
6182
6214
  controls' own handlers run first (they are on the targets), then the row repaints */
6183
6215
  ['input', 'change'].forEach(function (type) {
6184
6216
  $('reviewRows').addEventListener(type, function (ev) {
6217
+ /* the ship row's radios have no handler of their own (task 1004629) */
6218
+ if (type === 'change' && ev.target.name === 'shipMode') { state.shipMode = ev.target.value; saveDraft(); }
6185
6219
  if (ev.target.closest && ev.target.closest('.setRow.open .opts')) {
6186
6220
  var row = ev.target.closest('.setRow'), t = rowText(row.getAttribute('data-row'));
6187
6221
  row.querySelector('[data-row-v]').textContent = t.v;
@@ -6247,6 +6281,11 @@ summary{min-height:24px;padding:3px 0;}
6247
6281
  if (a.framing === 'open-source') return { v: 'Free core only', s: 'The free core, on your own machines.' };
6248
6282
  return { v: 'Everything, for you', s: 'Hosting, payouts and the starter kit, managed.' };
6249
6283
  }
6284
+ if (key === 'ship') {
6285
+ return shipModeChoice() === 'manual'
6286
+ ? { v: 'Manual', s: 'The AI waits for you to approve each ship. You can change this later in your project’s settings.' }
6287
+ : { v: 'Auto (recommended)', s: 'The AI ships finished work on its own. You can change this later in your project’s settings.' };
6288
+ }
6250
6289
  return { v: '', s: '' };
6251
6290
  }
6252
6291
  /* paint every row from the draft, and the locked Continue under them */
@@ -6407,7 +6446,9 @@ summary{min-height:24px;padding:3px 0;}
6407
6446
  existing history instead of pushing a fresh tree, which a repo that already
6408
6447
  has commits rejects as unrelated history. state.mode already tracked
6409
6448
  greenfield|adopt for the UI — it has to reach the server too. */
6410
- onboard_mode: state.mode === 'adopt' ? 'adopt' : 'greenfield'
6449
+ onboard_mode: state.mode === 'adopt' ? 'adopt' : 'greenfield',
6450
+ /* the ship row (task 1004629): becomes the new project's own ship mode */
6451
+ ship_mode: shipModeChoice()
6411
6452
  })
6412
6453
  });
6413
6454
  }).then(function (res) {
@@ -7175,7 +7216,7 @@ summary{min-height:24px;padding:3px 0;}
7175
7216
  is three-valued and null is its "nothing said" sentinel; leaving the key out
7176
7217
  left it UNDEFINED, which is not null — so pickedModules() reached .slice()
7177
7218
  on it and the second project's Modules step threw. */
7178
- state = { step: 1, name: '', projType: null, description: '', teamShape: null, modules: null, mode: 'greenfield', owner: '', ownerTouched: false, repo: '', repoTouched: false, slug: '', slugTouched: false, domain: '', noAddress: false, appPlatform: 'later', physics: null, demo: null, look: null, termsAck: false, confirmed: {} };
7219
+ state = { step: 1, name: '', projType: null, description: '', teamShape: null, modules: null, mode: 'greenfield', owner: '', ownerTouched: false, repo: '', repoTouched: false, slug: '', slugTouched: false, domain: '', noAddress: false, appPlatform: 'later', shipMode: 'auto', physics: null, demo: null, look: null, termsAck: false, confirmed: {} };
7179
7220
  $('newName').value = ''; $('f-owner').value = ''; $('f-repo').value = ''; $('f-slug').value = ''; $('f-domain').value = '';
7180
7221
  $('f-noaddr').checked = false; $('f-desc').value = '';
7181
7222
  $('termsAck').checked = false; paintDoors(); demoShow('intro'); checksShow('main');
@@ -55,6 +55,7 @@
55
55
  "in-new-review-extras": {"auth":true,"url":"/projects?view=new","actions":[["wait",600],["click","#termsAck"],["wait",100],["click","#wizStart"],["wait",400],["fill","#newName","Mercury"],["click","#typeCards .tpill[data-type=\"game\"]"],["fill","#f-desc","A co-op farming game where the seasons never repeat."],["click","#panel-what button[type=submit]"],["wait",400],["select","#physCountry","US"],["wait",100],["click","[data-phys=\"builder_geography\"][data-val=\"global\"]"],["click","[data-phys=\"minors_on_board\"][data-val=\"false\"]"],["click","[data-phys=\"entity\"][data-val=\"intent-solo\"]"],["click","#physNext"],["wait",700],["click","#reviewRows .setRow[data-row=\"extras\"] [data-row-chg]"],["wait",900]],"expect":{"visible":["#modQ","#modCards .tpill","#modCoreNote"]}},
56
56
  "in-new-review-team": {"auth":true,"url":"/projects?view=new","actions":[["wait",600],["click","#termsAck"],["wait",100],["click","#wizStart"],["wait",400],["fill","#newName","Mercury"],["click","#typeCards .tpill[data-type=\"game\"]"],["fill","#f-desc","A co-op farming game where the seasons never repeat."],["click","#panel-what button[type=submit]"],["wait",400],["select","#physCountry","US"],["wait",100],["click","[data-phys=\"builder_geography\"][data-val=\"global\"]"],["click","[data-phys=\"minors_on_board\"][data-val=\"false\"]"],["click","[data-phys=\"entity\"][data-val=\"intent-solo\"]"],["click","#physNext"],["wait",700],["click","#reviewRows .setRow[data-row=\"team\"] [data-row-chg]"],["wait",300],["click","[data-phys-solo=\"many\"]"],["wait",600]],"expect":{"visible":["#physTeam","#physGuessLine","#teamCards","#reviewRows .setRow[data-row=\"rewards\"]"]}},
57
57
  "in-new-review-run": {"auth":true,"url":"/projects?view=new","actions":[["wait",600],["click","#termsAck"],["wait",100],["click","#wizStart"],["wait",400],["fill","#newName","Mercury"],["click","#typeCards .tpill[data-type=\"game\"]"],["fill","#f-desc","A co-op farming game where the seasons never repeat."],["click","#panel-what button[type=submit]"],["wait",400],["select","#physCountry","US"],["wait",100],["click","[data-phys=\"builder_geography\"][data-val=\"global\"]"],["click","[data-phys=\"minors_on_board\"][data-val=\"false\"]"],["click","[data-phys=\"entity\"][data-val=\"intent-solo\"]"],["click","#physNext"],["wait",700],["click","#reviewRows .setRow[data-row=\"run\"] [data-row-chg]"],["wait",300],["click","[data-phys=\"framing\"][data-val=\"hybrid\"]"],["wait",600]],"expect":{"visible":["#physSuite"]}},
58
+ "in-new-review-ship": {"auth":true,"url":"/projects?view=new","actions":[["wait",600],["click","#termsAck"],["wait",100],["click","#wizStart"],["wait",400],["fill","#newName","Mercury"],["click","#typeCards .tpill[data-type=\"game\"]"],["fill","#f-desc","A co-op farming game where the seasons never repeat."],["click","#panel-what button[type=submit]"],["wait",400],["select","#physCountry","US"],["wait",100],["click","[data-phys=\"builder_geography\"][data-val=\"global\"]"],["click","[data-phys=\"minors_on_board\"][data-val=\"false\"]"],["click","[data-phys=\"entity\"][data-val=\"intent-solo\"]"],["click","#physNext"],["wait",700],["click","#reviewRows .setRow[data-row=\"ship\"] [data-row-chg]"],["wait",300],["click","#shipOpts input[value=\"manual\"]"],["wait",300]],"expect":{"visible":["#shipOpts"]}},
58
59
  "in-new-review-confirmed": {"auth":true,"url":"/projects?view=new","actions":[["wait",600],["click","#termsAck"],["wait",100],["click","#wizStart"],["wait",400],["fill","#newName","Mercury"],["click","#typeCards .tpill[data-type=\"game\"]"],["fill","#f-desc","A co-op farming game where the seasons never repeat."],["click","#panel-what button[type=submit]"],["wait",400],["select","#physCountry","US"],["wait",100],["click","[data-phys=\"builder_geography\"][data-val=\"global\"]"],["click","[data-phys=\"minors_on_board\"][data-val=\"false\"]"],["click","[data-phys=\"entity\"][data-val=\"intent-solo\"]"],["click","#physNext"],["wait",700],["click","#reviewRows .setRow[data-row=\"address\"] [data-row-chg]"],["wait",200],["fill","#f-slug","mercury"],["wait",900],["fill","#f-domain","mercury.example.com"],["wait",200],["click","#reviewRows .setRow[data-row=\"address\"] [data-row-chg]"],["wait",200]],"expect":{"visible":["#reviewGo","#reviewRows .setRow.done"],"text":[["#reviewGo","Looks right, continue"]]}},
59
60
  "in-new-look": {"auth":true,"url":"/projects?view=new","actions":[["wait",600],["click","#termsAck"],["wait",100],["click","#wizStart"],["wait",400],["fill","#newName","Mercury"],["click","#typeCards .tpill[data-type=\"game\"]"],["fill","#f-desc","A co-op farming game where the seasons never repeat."],["click","#panel-what button[type=submit]"],["wait",400],["select","#physCountry","US"],["wait",100],["click","[data-phys=\"builder_geography\"][data-val=\"global\"]"],["click","[data-phys=\"minors_on_board\"][data-val=\"false\"]"],["click","[data-phys=\"entity\"][data-val=\"intent-solo\"]"],["click","#physNext"],["wait",700],["click","#reviewRows .setRow[data-row=\"address\"] [data-row-chg]"],["wait",200],["fill","#f-slug","mercury"],["wait",900],["fill","#f-domain","mercury.example.com"],["wait",200],["click","#reviewRows .setRow[data-row=\"address\"] [data-row-chg]"],["wait",200],["click","#reviewGo"],["wait",500]],"expect":{"visible":["#panel-look","#wizLook","#wizRecap","#doCreate"],"hidden":["#panel-review"],"attr":[["#wizRoom","data-screen","look"]]}},
60
61
  "in-new-done": {"auth":true,"url":"/projects?view=new","actions":[["wait",600],["click","#termsAck"],["wait",100],["click","#wizStart"],["wait",400],["fill","#newName","Mercury"],["click","#typeCards .tpill[data-type=\"game\"]"],["fill","#f-desc","A co-op farming game where the seasons never repeat."],["click","#panel-what button[type=submit]"],["wait",400],["select","#physCountry","US"],["wait",100],["click","[data-phys=\"builder_geography\"][data-val=\"global\"]"],["click","[data-phys=\"minors_on_board\"][data-val=\"false\"]"],["click","[data-phys=\"entity\"][data-val=\"intent-solo\"]"],["click","#physNext"],["wait",700],["click","#reviewRows .setRow[data-row=\"address\"] [data-row-chg]"],["wait",200],["fill","#f-slug","mercury"],["wait",900],["fill","#f-domain","mercury.example.com"],["wait",200],["click","#reviewRows .setRow[data-row=\"address\"] [data-row-chg]"],["wait",200],["click","#reviewGo"],["wait",500],["click","#doCreate"],["wait",1500]],"expect":{"visible":["#panel-done","#hop","#hopCanvas","#hopStatus","#hopJump"],"hidden":["#panel-look","#qWrap","#wizBack","#wizInvite","#liveProgress","#humanSteps","#wizInviteLater","#stepPager"]}},
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.21.83",
3
+ "version": "1.21.85",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.21.83",
9
+ "version": "1.21.85",
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.83",
3
+ "version": "1.21.85",
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",
@@ -9324,5 +9324,17 @@
9324
9324
  "id": "1004646",
9325
9325
  "text": "Project invites now reach people on cloudbongos.com. Before, an invite sent by GitHub username was silently lost if the person had never signed in to a project through cloudbongos.com, which is almost every new invitee, so the"
9326
9326
  }
9327
+ ],
9328
+ "1.21.84": [
9329
+ {
9330
+ "id": "1004629",
9331
+ "text": "New projects now ask how finished work should ship: Auto (recommended) or Manual. The answer becomes the project's ship mode, which the owner can change later on the settings page."
9332
+ }
9333
+ ],
9334
+ "1.21.85": [
9335
+ {
9336
+ "id": "1004379",
9337
+ "text": "A page tweak that passed its grade and is waiting for its artist no longer reports itself as a failed grade: the closing card, the PR description and the credits line now all say it passed and is waiting for the artist's appro"
9338
+ }
9327
9339
  ]
9328
9340
  }
@@ -244,7 +244,8 @@ async function finishHeld({ taskId, summary, postGradeStatus, card }, deps = {})
244
244
  console.error(` Re-run to publish it again (the grade is not re-run): node scripts/gds/ship.js ${taskId}`);
245
245
  }
246
246
  const usage = await (deps.postShipSync || postShipSync)(taskId, 'confirmed');
247
- await (deps.printCompletionCard || printCompletionCard)({ taskId, postGradeStatus, card, usage, land });
247
+ // task 1004379: name the stage — at 'completed' the card would otherwise read a failed grade.
248
+ await (deps.printCompletionCard || printCompletionCard)({ taskId, postGradeStatus, card, usage, land, stage: 'held' });
248
249
  return land;
249
250
  }
250
251
 
@@ -270,7 +271,7 @@ function diffstatFor(baseline) {
270
271
  // in-feed HTML to a temp file and prints an [otb-card-html] marker the
271
272
  // builder-ship skill picks up to render it in the chat feed. Every step is
272
273
  // best-effort — a summary card must NEVER fail or undo a completed ship.
273
- async function printCompletionCard({ taskId, postGradeStatus, card, usage, land = null }) {
274
+ async function printCompletionCard({ taskId, postGradeStatus, card, usage, land = null, stage = undefined }) {
274
275
  if (!card) return;
275
276
  try {
276
277
  const shipCard = require('../../modules/lifecycle/ship-card');
@@ -329,6 +330,7 @@ async function printCompletionCard({ taskId, postGradeStatus, card, usage, land
329
330
  }
330
331
 
331
332
  const cardInputs = {
333
+ stage, // task 1004379: 'held' from finishHeld; undefined ⇒ derived from postGradeStatus
332
334
  postGradeStatus,
333
335
  taskId,
334
336
  title: card.title,
@@ -722,7 +722,7 @@ async function shipMain() {
722
722
 
723
723
  printCreditsAndAchievements({
724
724
  postGradeStatus, credits, creditsRaw, creditsMultiplier, ideaBonusInfo,
725
- justUnlocked: postGradeJustUnlocked,
725
+ justUnlocked: postGradeJustUnlocked, held,
726
726
  });
727
727
 
728
728
  // ---- Step 3: confirmed→shipped tail (merge + deploy → memory/session sync →
@@ -1107,6 +1107,7 @@ async function regradeMain() {
1107
1107
  const g = gradePost.data.grade;
1108
1108
  recordGradeOutcome({ taskId, grade: g });
1109
1109
  const postGradeStatus = gradePost.data.taskStatus;
1110
+ const held = gradePost.data.held === true; // task 1004322: a page tweak's pass waits for its artist
1110
1111
  printGradeOutcome(g, taskId);
1111
1112
  printCreditsAndAchievements({
1112
1113
  postGradeStatus,
@@ -1115,13 +1116,14 @@ async function regradeMain() {
1115
1116
  creditsMultiplier: gradePost.data.creditsMultiplier ?? null,
1116
1117
  ideaBonusInfo: gradePost.data.ideaBonus ?? null,
1117
1118
  justUnlocked: gradePost.data.just_unlocked || [],
1119
+ held, // task 1004379: a held pass is not 'grade not passed'
1118
1120
  });
1119
1121
 
1120
1122
  // On a pass the task is now confirmed — run the SAME confirmed→shipped tail as a
1121
1123
  // normal ship (finishShip, #1093/C9) so a re-grade lands identically.
1122
1124
  await finishShip({
1123
1125
  taskId, summary, advanceToMerge: gradePost.data.advanceToMerge, skipMerge, dbOnly,
1124
- held: gradePost.data.held === true, // task 1004322: a page tweak's pass waits for its artist
1126
+ held,
1125
1127
  postGradeStatus,
1126
1128
  doneLine: g.passed ? 'Done — re-grade passed.' : 'Done — re-grade still failing; fix and try again.',
1127
1129
  // task 1270: completion card for the re-grade path too.
@@ -478,9 +478,14 @@ function printGradeOutcome(g, taskId) {
478
478
 
479
479
  // Print the credits line + multiplier breakdown + idea bonus + unlocked
480
480
  // achievements from a grade/confirm response.
481
- function printCreditsAndAchievements({ postGradeStatus, credits, creditsRaw, creditsMultiplier, ideaBonusInfo, justUnlocked }) {
481
+ function printCreditsAndAchievements({ postGradeStatus, credits, creditsRaw, creditsMultiplier, ideaBonusInfo, justUnlocked, held = false }) {
482
482
  console.log('');
483
- if (postGradeStatus === 'completed') {
483
+ if (held) {
484
+ // task 1004379: a page tweak's PASS waits at completed for its artist (ADR
485
+ // 0341 D7). Reading 'completed' as 'grade not passed' told the reader the
486
+ // opposite of what happened.
487
+ console.log(`Credits awarded: ${credits} (none yet — the grade passed; credits land when the artist approves and it merges)`);
488
+ } else if (postGradeStatus === 'completed') {
484
489
  console.log(`Credits awarded: ${credits} (none — task at completed, grade not passed)`);
485
490
  } else if (postGradeStatus === 'confirmed') {
486
491
  console.log(`Credits awarded: ${credits} (none yet — credits land when the task merges to main)`);
@@ -19,6 +19,7 @@ const { plainCardLines } = require('./plain-cards.js');
19
19
  // The server's own reader of a PR's check rollup (task 1003988), reused so the laptop
20
20
  // poll names a red PR exactly the way publish-status does (task 1003764). Dependency-free.
21
21
  const { readCheckRollup, UNIT_REPORT_CONTEXT } = require('../../modules/lifecycle/check-rollup.js');
22
+ const { prMergeSentence } = require('../../modules/lifecycle/page-tweak-hold.js');
22
23
 
23
24
  // ---------- the land outcome (task 1002541, part 1) ----------
24
25
  //
@@ -927,8 +928,7 @@ async function ciLandLocal(taskId, branch, valueSummary, { hold = false } = {})
927
928
  `Auto-opened by ship.js (deploy mode: ci) for task ${taskId}.\n\n` +
928
929
  `${sanitizeGitHubRefs(valueSummary) || '(no value summary)'}\n\n` +
929
930
  `Merging this PR triggers .github/workflows/deploy-prod.yml to deploy to ` +
930
- `${deployTargetNote}. Auto-merge is enabled — GitHub lands it once the ` +
931
- `required checks pass.`;
931
+ `${deployTargetNote}. ${prMergeSentence(hold)}`; // task 1004379: a held round's PR says it waits
932
932
  if (!existing || existing.state === 'CLOSED' || existing.state === 'MERGED') {
933
933
  console.log(' ci-land: opening PR -> main...');
934
934
  if (!ghArgvOk(buildPrCreateArgv(branch, title, body))) {
@@ -226,14 +226,26 @@ function parseShipMode(raw) {
226
226
  // resolveShipMode() — the project's ship mode and where it came from.
227
227
  // Returns { mode, source, chosen, invalid }:
228
228
  // mode — always 'auto' or 'manual'
229
- // source — 'project_settings' | 'default'
229
+ // source — 'project_settings' | 'wizard' | 'default'
230
230
  // chosen — true only when someone actually SET a usable value. "Never chosen" is
231
231
  // a real answer the first-ship prompt (task 1004630) asks about, so it
232
232
  // must not be confused with "chose the default".
233
233
  // invalid — null, or { where, raw, reason } for a stored value that is unusable.
234
234
  // A database that cannot be read answers the default rather than failing a ship —
235
235
  // the same reporting posture as resolveRotDays.
236
- async function resolveShipMode() {
236
+ //
237
+ // 'wizard' (task 1004629): the founder picked a mode in the project-creation wizard,
238
+ // which reaches this instance as the server-only branding value founding.shipMode
239
+ // (the hub cannot write this database). It counts as chosen, but only while no row
240
+ // exists: the settings page and the CLI write the row, so a later change always wins.
241
+ // `deps.branding` is the test seam; production reads the process's own pack.
242
+ function wizardShipMode(deps) {
243
+ let pack;
244
+ try { pack = deps.branding ? deps.branding() : require('../branding').branding(); } catch (_) { return null; }
245
+ return parseShipMode(pack && pack.founding && pack.founding.shipMode).mode || null;
246
+ }
247
+
248
+ async function resolveShipMode(deps = {}) {
237
249
  let row = null;
238
250
  try {
239
251
  row = await getSetting(SETTING_KEYS.SHIP_MODE);
@@ -251,6 +263,8 @@ async function resolveShipMode() {
251
263
  invalid: { where: `project_settings['${SETTING_KEYS.SHIP_MODE}']`, raw: String(row.value), reason: got.reason },
252
264
  };
253
265
  }
266
+ const fromWizard = wizardShipMode(deps);
267
+ if (fromWizard) return { mode: fromWizard, source: 'wizard', chosen: true, invalid: null };
254
268
  return { mode: DEFAULT_SHIP_MODE, source: 'default', chosen: false, invalid: null };
255
269
  }
256
270
 
package/src/branding.js CHANGED
@@ -103,6 +103,9 @@ const ENV_OVERRIDES = [
103
103
  ['FOUNDING_BIRTH', ['founding', 'birth']],
104
104
  ['DEMO_PEOPLE', ['founding', 'demoPeople']],
105
105
  ['DEMO_HOURS', ['founding', 'demoHours']],
106
+ // The ship mode the founder picked in the wizard (task 1004629), empty when none was.
107
+ // resolveShipMode reads it only while project_settings['ship.mode'] is unset.
108
+ ['SHIP_MODE', ['founding', 'shipMode']],
106
109
  ];
107
110
 
108
111
  const isObj = (v) => v && typeof v === 'object' && !Array.isArray(v);
package/src/module-api.js CHANGED
@@ -75,7 +75,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
75
75
  // MAJOR (see allowBoxScope below): passes the request through untouched.
76
76
  function deprecatedNoopMiddleware(_req, _res, next) { next(); }
77
77
 
78
- const CORE_VERSION = '1.21.83'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
78
+ const CORE_VERSION = '1.21.85'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
79
79
 
80
80
  // A namespaced logger so a module's log lines are attributable + consistent.
81
81
  // Usage: const log = api.logger('discord'); log.info('mounted');
@@ -0,0 +1,164 @@
1
+ // tests/held_tweak_reporting.mjs — a held page tweak is reported as a PASS that
2
+ // waits for its artist, never as a failed grade (task 1004379, ADR 0341 D7).
3
+ //
4
+ // WHY. The first live Tweak Mode rounds (1004345, 1004348) passed their grade and
5
+ // held at completed for the artist, as designed — and then three surfaces told the
6
+ // reader the opposite: the closing card said "grade failed" and offered --regrade,
7
+ // the PR the server opened said "Auto-merge is enabled", and a passing --regrade
8
+ // printed "Credits awarded: 0 (grade not passed)". Each case below drives the real
9
+ // code a held round runs through:
10
+ // 1. finishHeld hands the card a 'held' stage, and that card reads as a pass;
11
+ // 2. the publish route's PR body names the hold (and an ordinary PR is unchanged);
12
+ // 3. the credits line a passing (re-)grade prints on a held round says it passed.
13
+
14
+ import { strict as assert } from 'node:assert';
15
+ import { createRequire } from 'node:module';
16
+ import http from 'node:http';
17
+ import { makeRunner } from './helpers.mjs';
18
+
19
+ process.env.NODE_ENV = 'test';
20
+ const require = createRequire(import.meta.url);
21
+ const { test, summary } = makeRunner();
22
+
23
+ const shipCard = require('../modules/lifecycle/ship-card.js');
24
+ const shipFinish = require('../scripts/gds/ship-finish.js');
25
+ const shipLand = require('../scripts/gds/ship-land.js');
26
+ const { printCreditsAndAchievements } = require('../scripts/gds/ship-grade-steps.js');
27
+
28
+ const capture = async (fn) => {
29
+ const out = [];
30
+ const orig = console.log;
31
+ console.log = (...a) => out.push(a.join(' '));
32
+ try { await fn(); } finally { console.log = orig; }
33
+ return out.join('\n');
34
+ };
35
+
36
+ // ---- 1. the closing card --------------------------------------------------
37
+
38
+ const twelveMinor = Array.from({ length: 12 }, (_, i) => ({ severity: 'minor', file: `f${i}`, summary: 'nit' }));
39
+
40
+ await test('finishHeld names the held stage to the card, even though the task sits at completed', async () => {
41
+ let cardArgs = null;
42
+ await capture(() => shipFinish.finishHeld({ taskId: 5001, summary: 's', postGradeStatus: 'completed', card: { title: 't' } }, {
43
+ publishHeld: async () => shipLand.landHeld({ prUrl: 'https://github.com/o/r/pull/77' }),
44
+ postShipSync: async () => null,
45
+ printCompletionCard: async (a) => { cardArgs = a; },
46
+ }));
47
+ assert.equal(cardArgs.stage, 'held');
48
+ assert.equal(cardArgs.postGradeStatus, 'completed');
49
+ });
50
+
51
+ await test('a held pass prints a hold card: no "grade failed", no --regrade, a passing grade row', () => {
52
+ const model = shipCard.buildCardModel({
53
+ stage: 'held', postGradeStatus: 'completed', taskId: 5001, title: 't',
54
+ grade: { passed: true, issues: twelveMinor, panel: 4 },
55
+ credits: { awarded: 0 },
56
+ });
57
+ const text = shipCard.renderMonospaceCard(model);
58
+ const html = shipCard.renderCardHtml(model);
59
+ assert.equal(model.stage, 'held');
60
+ assert.match(model.cfg.headline(5001), /waiting for the artist/);
61
+ for (const out of [text, html]) {
62
+ assert.doesNotMatch(out, /grade failed/i, 'the card must not call a pass a failure');
63
+ assert.doesNotMatch(out, /Needs fixes/);
64
+ assert.doesNotMatch(out, /--regrade/);
65
+ assert.doesNotMatch(out, /fail · 12 issues/);
66
+ assert.match(out, /awaiting artist/);
67
+ }
68
+ const row = (k) => (model.rows.find((r) => r.k === k) || {}).v;
69
+ assert.match(row('Grade'), /^pass/);
70
+ assert.match(row('Deploy'), /artist/);
71
+ assert.match(row('Credits'), /artist approves/);
72
+ assert.equal(model.action.cmd, '/builder-start');
73
+ });
74
+
75
+ await test('an ordinary stop at completed (a real failed grade) still reads as a failed grade', () => {
76
+ const model = shipCard.buildCardModel({
77
+ postGradeStatus: 'completed', taskId: 5002, title: 't',
78
+ grade: { passed: false, issues: twelveMinor },
79
+ });
80
+ assert.equal(model.stage, 'failed');
81
+ assert.match(shipCard.renderMonospaceCard(model), /grade failed/);
82
+ assert.match(model.action.cmd, /--regrade/);
83
+ });
84
+
85
+ // ---- 2. the PR body -------------------------------------------------------
86
+
87
+ const express = require('express');
88
+ const api = require('../src/module-api.js');
89
+ const db = require('../modules/lifecycle/db.js');
90
+ const gp = require('../modules/lifecycle/github-push.js');
91
+ const prAssign = require('../modules/lifecycle/pr-assign.js');
92
+ const pageTweakReads = require('../modules/lifecycle/page-tweak-reads.js');
93
+ const hold = require('../modules/lifecycle/page-tweak-hold.js');
94
+
95
+ let heldNow = false;
96
+ let prBody = null;
97
+ api.requireBuilder = (req, res, next) => { req.builder = { id: 1, rank: 'archon', github_login: 'the-archon' }; next(); };
98
+ db.getLastClaimHolder = async () => ({ builder_id: 5, github_login: 'some-builder' });
99
+ db.setClaimPublishedSha = async () => {};
100
+ gp.isConfigured = () => true;
101
+ gp.pushBundle = async () => ({ head_sha: 'a'.repeat(40) });
102
+ gp.openOrFindPullRequest = async ({ body }) => { prBody = body; return { number: 77, url: 'https://github.com/o/r/pull/77', node_id: 'N' }; };
103
+ gp.enableAutoMergeDetailed = async () => ({ ok: true });
104
+ gp.setCommitStatus = async () => true;
105
+ prAssign.assignPullRequest = async () => ({ assigned: true });
106
+ pageTweakReads.heldForArtistFailClosed = async () => heldNow;
107
+
108
+ const app = express();
109
+ app.use((req, res, next) => { res.fail = (code, status, extra) => res.status(typeof status === 'number' ? status : (status && status.status) || 500).json({ error: code, ...(extra || {}) }); next(); });
110
+ app.use(require('../modules/lifecycle/routes/tasks.js')());
111
+ const server = http.createServer(app);
112
+ await new Promise((r) => server.listen(0, '127.0.0.1', r));
113
+ const base = `http://127.0.0.1:${server.address().port}`;
114
+ const publish = async () => {
115
+ prBody = null;
116
+ const res = await fetch(`${base}/tasks/5001/publish-branch`, {
117
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
118
+ body: JSON.stringify({ branch: 'task-5001', head_sha: 'a'.repeat(40), bundle_b64: Buffer.from('b').toString('base64'), value_summary: 'Page tweak.' }),
119
+ });
120
+ return { status: res.status, body: await res.json() };
121
+ };
122
+
123
+ await test("a held round's PR body says it waits for the artist, and never promises auto-merge", async () => {
124
+ heldNow = true;
125
+ const r = await publish();
126
+ assert.equal(r.status, 201, JSON.stringify(r.body));
127
+ assert.equal(r.body.auto_merge, false);
128
+ assert.ok(prBody.endsWith(hold.HELD_PR_SENTENCE), prBody);
129
+ assert.match(prBody, /artist/);
130
+ assert.doesNotMatch(prBody, /Auto-merge is enabled/);
131
+ });
132
+
133
+ await test('every other PR body keeps the auto-merge sentence it always had', async () => {
134
+ heldNow = false;
135
+ const r = await publish();
136
+ assert.equal(r.status, 201);
137
+ assert.equal(r.body.auto_merge, true);
138
+ assert.match(prBody, /Auto-merge is enabled — GitHub lands it once the required checks pass\.$/);
139
+ });
140
+ server.close();
141
+
142
+ // ---- 3. the credits line after a passing (re-)grade -----------------------
143
+
144
+ await test('a passing grade on a held round prints a pass, not "grade not passed"', async () => {
145
+ const text = await capture(() => printCreditsAndAchievements({ postGradeStatus: 'completed', credits: 0, justUnlocked: [], held: true }));
146
+ assert.doesNotMatch(text, /grade not passed/);
147
+ assert.match(text, /grade passed/);
148
+ assert.match(text, /artist approves/);
149
+ });
150
+
151
+ await test('a real failed grade at completed still says the grade did not pass', async () => {
152
+ const text = await capture(() => printCreditsAndAchievements({ postGradeStatus: 'completed', credits: 0, justUnlocked: [] }));
153
+ assert.match(text, /grade not passed/);
154
+ });
155
+
156
+ await test('both grade paths (ship and --regrade) hand the hold to the credits line', () => {
157
+ const fs = require('node:fs');
158
+ const src = fs.readFileSync(new URL('../scripts/gds/ship-flow.js', import.meta.url), 'utf8');
159
+ const calls = src.split('printCreditsAndAchievements({').slice(1).map((c) => c.slice(0, c.indexOf('});')));
160
+ const withHold = calls.filter((c) => /\bheld\b/.test(c));
161
+ assert.equal(withHold.length, 2, 'the ship grade and the re-grade both pass held');
162
+ });
163
+
164
+ summary();
@@ -206,8 +206,7 @@ await test('finishShip routes a held pass away from mergeAndDeploy (the source s
206
206
  const mergeAt = body.indexOf('await mergeAndDeploy(');
207
207
  assert.ok(holdAt > 0 && mergeAt > holdAt, 'the hold is decided BEFORE any merge is attempted');
208
208
  const flow = fs.readFileSync(require.resolve('../scripts/gds/ship-flow.js'), 'utf8');
209
- assert.equal((flow.match(/held = gradePost\.data\.held === true/g) || []).length, 1, 'shipMain reads the hold off the grade response');
210
- assert.equal((flow.match(/held: gradePost\.data\.held === true/g) || []).length, 1, 'regradeMain does too');
209
+ assert.equal((flow.match(/\bheld = gradePost\.data\.held === true/g) || []).length, 2, 'shipMain and regradeMain both read the hold off the grade response');
211
210
  assert.match(flow, /tweakHold\.isHeldForArtist\(task, tRes\.data\.grade/, 'a re-run of a held task re-publishes instead of re-grading');
212
211
  });
213
212
 
@@ -729,7 +729,7 @@ test('the About answers travel on the create request, and an empty one is OMITTE
729
729
  // wizard PREFILLED, and the sentence is never prefilled, so it has no row there — its
730
730
  // cost is stated on its own field (pinned above)
731
731
  assert.doesNotMatch(SCRIPT, /function renderReview\(|\$\('reviewList'\)/, 'no ledger review is left');
732
- assert.match(SCRIPT, /var ROWS = \['code', 'address', 'app', 'extras', 'team', 'rewards', 'run'\];/,
732
+ assert.match(SCRIPT, /var ROWS = \['code', 'address', 'app', 'extras', 'team', 'rewards', 'run', 'ship'\];/,
733
733
  'the review rows are the seven prefilled answers');
734
734
  });
735
735
 
@@ -168,7 +168,7 @@ test('the row is real markup: Render and Decide later, Decide later the default'
168
168
 
169
169
  test("the row sits in the review, between Address and Extras, and its checks are the old step's (task 1004504)", () => {
170
170
  assert.match(HUB, /var panels = \{ 0: 'panel-gate', 1: 'panel-fork', 2: 'panel-what', 3: 'panel-checks', 4: 'panel-review', 5: 'panel-look', 6: 'panel-done' \};/);
171
- assert.match(HUB, /var ROWS = \['code', 'address', 'app', 'extras', 'team', 'rewards', 'run'\];/);
171
+ assert.match(HUB, /var ROWS = \['code', 'address', 'app', 'extras', 'team', 'rewards', 'run', 'ship'\];/);
172
172
  assert.doesNotMatch(HUB, /<li[^>]*data-step="\d+"/, 'no rail beat names the step any more');
173
173
  assert.match(HUB, /if \(step === 4\) \{[^}]*syncAppStep\(\); syncRenderSetup\(\);\r?\n\s*paintSetup\(\);\r?\n\s*\}/, 'entering the review repaints the row, Render setup included');
174
174
  assert.match(HUB, /if \(key === 'app'\) \{ syncAppStep\(\); syncRenderSetup\(\); \}/, 'opening the row repaints it too');