@bongos/core 1.19.669 → 1.19.671

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.
@@ -1797,5 +1797,9 @@ is load-bearing: the script throws rather than guess if it is missing, and
1797
1797
  landed since 1.19.667 with no explicit bump. run 34618700494. (task 1002620)
1798
1798
  1.19.669 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1799
1799
  landed since 1.19.668 with no explicit bump. run 34619821252. (task 1002620)
1800
+ 1.19.670 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1801
+ landed since 1.19.669 with no explicit bump. run 34622305231. (task 1002620)
1802
+ 1.19.671 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1803
+ landed since 1.19.670 with no explicit bump. run 34627255469. (task 1002620)
1800
1804
  ---------------------------------------------------------------------------
1801
1805
  ```
@@ -49,6 +49,14 @@ function onboardPlan(mode = 'greenfield', opts = {}) {
49
49
  // collects it, so it is not one of the irreducibly-manual legs a done panel
50
50
  // lists. `optional` is what makes it skippable — see the header.
51
51
  { key: 'project-detail', kind: 'auto', optional: true, label: 'Tell us about your project — optional, skip it and add it later' },
52
+ // The creation picker's module choice (BV1.R20/R24, task 1002339). 'auto' and
53
+ // optional for project-detail's reasons, and it sits immediately after it
54
+ // because it READS that step's answer: the declared `type` keys the starter
55
+ // bundle this step preselects (ADR 0237). Skipping stores nothing and the
56
+ // bundle is resolved on read (ADR 0240), so a skip yields exactly the set the
57
+ // picker had ticked — which is why the label says "take the recommended set"
58
+ // rather than "add them later": the modules are already there.
59
+ { key: 'project-modules', kind: 'auto', optional: true, label: 'Pick its extras — optional, skip it to take the recommended set for your project type' },
52
60
  { key: 'github-repo', kind: 'human', label: adopt ? 'Use your EXISTING GitHub repository' : 'Create the empty GitHub repository' },
53
61
  { key: 'dns-preflight', kind: 'auto', shapes: CONTROL_PLANE_SHAPES, label: 'DNS token preflight — before the address step (task 1980)' },
54
62
  { key: 'init', kind: 'auto', label: adopt ? 'bongos init --adopt — LAYER onto the existing repo (pre-flight + accept)' : 'bongos init — scaffold the instance config' },
@@ -1585,6 +1585,12 @@ p.sec-p{font-size:var(--fs-lede);font-weight:500;color:var(--ink-soft);line-heig
1585
1585
  #view-new .panel .lede strong,#view-new .panel .lede em{color:var(--ink);font-weight:500;font-style:normal;}
1586
1586
  /* the done clause's link to Your projects clears the 24px hit floor as a box (an inline's rect is its content box) */
1587
1587
  #view-new .panel .lede a{display:inline-block;padding:3px 0;min-height:24px;line-height:18px;color:var(--ink);font-weight:400;text-decoration:underline;text-underline-offset:3px;}
1588
+ /* the "still watching …" line above step 1 (task 1003503): a quiet offer, not a
1589
+ banner — the fresh wizard is what the click asked for. Its link clears the 24px
1590
+ hit floor as a box, the same way the lede's does. */
1591
+ #view-new .resumeOffer{font-weight:400;font-size:13.5px;line-height:1.5;color:var(--ink-soft);margin:0 0 22px;max-width:62ch;}
1592
+ #view-new .resumeOffer strong{color:var(--ink);font-weight:500;}
1593
+ #view-new .resumeOffer a{display:inline-block;padding:3px 0;min-height:24px;line-height:18px;color:var(--ink);font-weight:400;text-decoration:underline;text-underline-offset:3px;}
1588
1594
  #view-new .finePrint{font-weight:400;font-size:13.5px;line-height:1.5;color:var(--ink-soft);margin-top:18px;max-width:62ch;}
1589
1595
  #view-new .finePrint a{color:var(--ink);font-weight:400;text-decoration:underline;text-underline-offset:3px;}
1590
1596
  #view-new .finePrint em{font-style:normal;color:var(--ink);}
@@ -978,6 +978,11 @@ summary{min-height:24px;padding:3px 0;}
978
978
  <h1 id="name-h">Start a project</h1>
979
979
  <p class="lede">Your own project: its own repository, its own home on the web, and a hall of AI builders
980
980
  who do the building. You set the direction; agents lay down the code.</p>
981
+ <!-- The standup already being watched, kept one click away (task 1003503).
982
+ A deliberate "New project" click opens THIS fresh step 1; the previous
983
+ project's done panel is reachable here instead of taking the panel over.
984
+ Baked markup, hidden until enter() has a record to name. -->
985
+ <p class="resumeOffer" id="wizResume" hidden>still watching <strong id="wizResumeName">your project</strong> — <a href="#" id="wizResumeGo">resume it</a></p>
981
986
  <form id="newForm" data-next="2">
982
987
  <label class="wizField"><span>Project name</span>
983
988
  <input type="text" id="newName" placeholder="What should it be called?" autocomplete="off" maxlength="80" aria-label="Project name">
@@ -4613,6 +4618,36 @@ summary{min-height:24px;padding:3px 0;}
4613
4618
  tick();
4614
4619
  }
4615
4620
 
4621
+ /* Everything the wizard must FORGET to start a second project: the watch, the
4622
+ manifest leg, the answers, the fields. Deliberately not the created record —
4623
+ the caller owns that. "Start another project" drops it (the founder said they
4624
+ were done); the intent-driven resume below keeps it, so the standup stays one
4625
+ click away while a fresh step 1 is filled in. */
4626
+ function resetForNewProject() {
4627
+ stopPolling();
4628
+ stopManifestPoll();
4629
+ instanceId = null;
4630
+ watchPending = false; manifestPending = false; manifestState = null;
4631
+ manifestStartedAt = 0; manifestStale = false;
4632
+ live = { instStatus: null, manifest: null, manifestMsg: '', manifestCode: '', appSlug: null };
4633
+ lastStepsHtml = '';
4634
+ lastProgHtml = '';
4635
+ try { sessionStorage.removeItem(DRAFT_KEY); } catch (e) {}
4636
+ /* Every key the initial state declares has to be re-declared here. `modules`
4637
+ is three-valued and null is its "nothing said" sentinel; leaving the key out
4638
+ left it UNDEFINED, which is not null — so pickedModules() reached .slice()
4639
+ on it and the second project's Modules step threw. */
4640
+ 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 };
4641
+ $('newName').value = ''; $('f-owner').value = ''; $('f-repo').value = ''; $('f-slug').value = ''; $('f-domain').value = '';
4642
+ $('f-noaddr').checked = false; $('f-desc').value = '';
4643
+ /* Re-seed the owner from the signed-in account for the next project. */
4644
+ if (me && me.github_login) { state.owner = me.github_login; $('f-owner').value = state.owner; }
4645
+ $('h-name').innerHTML = '&nbsp;';
4646
+ paintType(null);
4647
+ paintTeam(null);
4648
+ setMode('greenfield');
4649
+ }
4650
+
4616
4651
  /* start another project: clear the stored instance + draft, back to step 1 */
4617
4652
  function wireAgain() {
4618
4653
  var again = document.createElement('button');
@@ -4620,24 +4655,11 @@ summary{min-height:24px;padding:3px 0;}
4620
4655
  again.addEventListener('click', function () {
4621
4656
  /* a standup still being watched is not abandoned by accident */
4622
4657
  if (instanceId && pollTimer && !confirm((state.name || state.slug) + ' keeps building — you’ll find it on your projects page. Start a new one?')) return;
4623
- stopPolling();
4624
- stopManifestPoll();
4625
- instanceId = null;
4626
- watchPending = false; manifestPending = false; manifestState = null;
4627
- manifestStartedAt = 0; manifestStale = false;
4628
- live = { instStatus: null, manifest: null, manifestMsg: '', manifestCode: '', appSlug: null };
4629
- lastStepsHtml = '';
4630
- lastProgHtml = '';
4631
- try { sessionStorage.removeItem(CREATED_KEY); sessionStorage.removeItem(DRAFT_KEY); } catch (e) {}
4632
- state = { step: 1, name: '', projType: null, description: '', teamShape: null, mode: 'greenfield', owner: '', ownerTouched: false, repo: '', repoTouched: false, slug: '', slugTouched: false, domain: '', noAddress: false };
4633
- $('newName').value = ''; $('f-owner').value = ''; $('f-repo').value = ''; $('f-slug').value = ''; $('f-domain').value = '';
4634
- $('f-noaddr').checked = false; $('f-desc').value = '';
4635
- /* Re-seed the owner from the signed-in account for the next project. */
4636
- if (me && me.github_login) { state.owner = me.github_login; $('f-owner').value = state.owner; }
4637
- $('h-name').innerHTML = '&nbsp;';
4638
- paintType(null);
4639
- paintTeam(null);
4640
- setMode('greenfield');
4658
+ resetForNewProject();
4659
+ /* the deliberate goodbye: the record goes too, so nothing offers to resume
4660
+ the project the founder has just said they are finished watching */
4661
+ try { sessionStorage.removeItem(CREATED_KEY); } catch (e) {}
4662
+ paintResumeOffer(null);
4641
4663
  show(1, true);
4642
4664
  });
4643
4665
  $('panel-done').appendChild(again);
@@ -4648,6 +4670,81 @@ summary{min-height:24px;padding:3px 0;}
4648
4670
  function createdRecord() {
4649
4671
  try { return JSON.parse(sessionStorage.getItem(CREATED_KEY) || 'null'); } catch (e) { return null; }
4650
4672
  }
4673
+ /* The "still watching …" offer above step 1 (task 1003503). Its markup is baked
4674
+ into the panel, so this only names the project and shows or hides the line —
4675
+ this surface builds no live DOM from strings. null takes it down. */
4676
+ function paintResumeOffer(created) {
4677
+ var el = $('wizResume');
4678
+ if (!el) return;
4679
+ if (created && created.id) {
4680
+ $('wizResumeName').textContent = created.name || created.slug || 'your project';
4681
+ el.hidden = false;
4682
+ } else {
4683
+ el.hidden = true;
4684
+ }
4685
+ }
4686
+ /* Raised by the resume link so the enter() it triggers reads as "yes, the done
4687
+ panel" instead of one more ask for a fresh wizard. One-shot: enter() lowers it. */
4688
+ var resumeIntent = false;
4689
+ $('wizResumeGo').addEventListener('click', function (ev) {
4690
+ ev.preventDefault();
4691
+ resumeIntent = true;
4692
+ enter(true);
4693
+ });
4694
+
4695
+ /* enterCreated(created, focus) — a stored record means a standup is (or was)
4696
+ being watched, and WHO IS ARRIVING decides what that means (task 1003503,
4697
+ owner call 2026-09-11):
4698
+
4699
+ • a reload, Back, or the identity settle (focus false) — the same visit
4700
+ continuing, so the done panel and its human-steps checklist come back
4701
+ untouched. Losing the checklist there would be the bug;
4702
+ • a deliberate "New project" click (focus true) — an ask for ANOTHER
4703
+ project, so it opens a fresh step 1 and leaves the standup one click
4704
+ away on the resume link. That nav used to land on the previous
4705
+ project's checklist for the rest of the tab session, with "Start
4706
+ another project" the only way out.
4707
+
4708
+ The reset fires only when there is a SECOND DRAFT to lose, and the draft's own
4709
+ step is what says so. A landed create clears DRAFT_KEY — but show(8) then
4710
+ persists the done panel's own position straight back into it, so "is there a
4711
+ draft?" is always yes and would have skipped the reset forever, leaving the
4712
+ finished project's name sitting in the fresh form. Only steps 1–7 are wizard
4713
+ steps: anything else is the done panel's bookkeeping, not a project someone
4714
+ is part-way through typing. */
4715
+ function enterCreated(created, focus) {
4716
+ if (focus && !resumeIntent) {
4717
+ var draft = null;
4718
+ try { draft = JSON.parse(sessionStorage.getItem(DRAFT_KEY) || 'null'); } catch (e) {}
4719
+ var at = draft && draft.step >= 1 && draft.step <= 7 ? draft.step : 0;
4720
+ if (!at) { resetForNewProject(); at = 1; }
4721
+ paintResumeOffer(created);
4722
+ /* step 1's own focus target is the name field, not the heading — the same
4723
+ deliberate-nav rule enter() applies below */
4724
+ show(at, at !== 1);
4725
+ if (at === 1) $('newName').focus();
4726
+ return;
4727
+ }
4728
+ resumeIntent = false;
4729
+ paintResumeOffer(null);
4730
+ /* returning after a create — resume watching that instance instead of a
4731
+ blank wizard */
4732
+ state.name = created.name || created.slug; state.slug = created.slug || '';
4733
+ state.domain = created.domain || ''; state.mode = created.mode || 'greenfield';
4734
+ /* restore the opt-out too — without it resolvedDomain() re-derives the
4735
+ assigned address and the done panel promises one that doesn't exist */
4736
+ state.noAddress = !!created.noaddr;
4737
+ /* the manifest leg's last known truth, kept across a reload */
4738
+ if (created.manifest) { live.manifest = created.manifest; live.appSlug = created.app_slug || null; }
4739
+ if (created.manifest_state && created.manifest !== 'done' && created.manifest !== 'error' && created.manifest !== 'expired') {
4740
+ manifestState = created.manifest_state; manifestPending = true;
4741
+ }
4742
+ renderDone();
4743
+ show(8, !!focus);
4744
+ startProgress(created.id);
4745
+ resumePolls();
4746
+ }
4747
+
4651
4748
  /* enter(focus) — paint the right step for whoever just arrived. Before the
4652
4749
  identity settles it shows the draft step optimistically (the page stays
4653
4750
  usable while /me is in flight — the boot's own rule); onAuth() re-enters
@@ -4655,25 +4752,8 @@ summary{min-height:24px;padding:3px 0;}
4655
4752
  function enter(focus) {
4656
4753
  if (authKnown && !me) { show(0, !!focus); return; }
4657
4754
  var created = authKnown ? createdRecord() : null;
4658
- if (created && created.id) {
4659
- /* returning after a create — resume watching that instance instead of a
4660
- blank wizard */
4661
- state.name = created.name || created.slug; state.slug = created.slug || '';
4662
- state.domain = created.domain || ''; state.mode = created.mode || 'greenfield';
4663
- /* restore the opt-out too — without it resolvedDomain() re-derives the
4664
- assigned address and the done panel promises one that doesn't exist */
4665
- state.noAddress = !!created.noaddr;
4666
- /* the manifest leg's last known truth, kept across a reload */
4667
- if (created.manifest) { live.manifest = created.manifest; live.appSlug = created.app_slug || null; }
4668
- if (created.manifest_state && created.manifest !== 'done' && created.manifest !== 'error' && created.manifest !== 'expired') {
4669
- manifestState = created.manifest_state; manifestPending = true;
4670
- }
4671
- renderDone();
4672
- show(8, !!focus);
4673
- startProgress(created.id);
4674
- resumePolls();
4675
- return;
4676
- }
4755
+ if (created && created.id) { enterCreated(created, !!focus); return; }
4756
+ paintResumeOffer(null);
4677
4757
  /* before identity settles, never render past the name step: deeper steps
4678
4758
  carry authenticated side effects (the Home-step repo probe), and onAuth()
4679
4759
  re-enters to the real draft step the moment /me answers */
@@ -37,6 +37,7 @@
37
37
  "in-new-address": { "auth": true, "url": "/projects?view=new", "actions": [["wait",600],["fill","#newName","Mercury"],["click","#panel-name button[type=submit]"],["wait",400],["click","#typeCards .tpill[data-type=\"game\"]"],["click","#panel-type button[type=submit]"],["wait",400],["fill","#f-desc","A co-op farming game where the seasons never repeat."],["click","#teamCards .tpill[data-team=\"small-team\"]"],["click","#panel-detail button[type=submit]"],["wait",900],["click","#panel-modules button[type=submit]"],["wait",400],["click","#panel-home button[type=submit]"],["wait",400]], "expect": {"visible":["#panel-address"],"hidden":["#panel-home","#qWrap"]} },
38
38
  "in-new-review": { "auth": true, "url": "/projects?view=new", "actions": [["wait",600],["fill","#newName","Mercury"],["click","#panel-name button[type=submit]"],["wait",400],["click","#typeCards .tpill[data-type=\"game\"]"],["click","#panel-type button[type=submit]"],["wait",400],["fill","#f-desc","A co-op farming game where the seasons never repeat."],["click","#teamCards .tpill[data-team=\"small-team\"]"],["click","#panel-detail button[type=submit]"],["wait",900],["click","#panel-modules button[type=submit]"],["wait",400],["click","#panel-home button[type=submit]"],["wait",400],["fill","#f-slug","mercury"],["wait",900],["fill","#f-domain","mercury.example.com"],["wait",200],["click","#panel-address button[type=submit]"],["wait",400]], "expect": {"visible":["#panel-review","#reviewList li"],"hidden":["#panel-address","#qWrap"]} },
39
39
  "in-new-done": { "auth": true, "url": "/projects?view=new", "actions": [["wait",600],["fill","#newName","Mercury"],["click","#panel-name button[type=submit]"],["wait",400],["click","#typeCards .tpill[data-type=\"game\"]"],["click","#panel-type button[type=submit]"],["wait",400],["fill","#f-desc","A co-op farming game where the seasons never repeat."],["click","#teamCards .tpill[data-team=\"small-team\"]"],["click","#panel-detail button[type=submit]"],["wait",900],["click","#panel-modules button[type=submit]"],["wait",400],["click","#panel-home button[type=submit]"],["wait",400],["fill","#f-slug","mercury"],["wait",900],["fill","#f-domain","mercury.example.com"],["wait",200],["click","#panel-address button[type=submit]"],["wait",400],["click","#doCreate"],["wait",1500]], "expect": {"visible":["#panel-done","#liveProgress .prog","#humanSteps li"],"hidden":["#panel-review","#qWrap"]} },
40
+ "in-new-resume-offer": { "auth": true, "url": "/projects?view=new", "actions": [["wait",600],["fill","#newName","Mercury"],["click","#panel-name button[type=submit]"],["wait",400],["click","#typeCards .tpill[data-type=\"game\"]"],["click","#panel-type button[type=submit]"],["wait",400],["fill","#f-desc","A co-op farming game where the seasons never repeat."],["click","#teamCards .tpill[data-team=\"small-team\"]"],["click","#panel-detail button[type=submit]"],["wait",900],["click","#panel-modules button[type=submit]"],["wait",400],["click","#panel-home button[type=submit]"],["wait",400],["fill","#f-slug","mercury"],["wait",900],["fill","#f-domain","mercury.example.com"],["wait",200],["click","#panel-address button[type=submit]"],["wait",400],["click","#doCreate"],["wait",1500],["click","#projTrig"],["wait",350],["click","#menuProjects [data-nav=\"new\"]"],["wait",500]], "expect": {"visible":["#panel-name","#wizResume","#wizResumeGo"],"hidden":["#panel-done"],"text":[["#wizResumeName","Mercury"]]} },
40
41
  "out-map-projects": { "auth": false, "actions": [["click", "#projTrig"], ["wait", 350]], "expect": { "expanded": true, "hidden": ["#menuProjects [data-nav=\"mine\"]"] } },
41
42
  "card": { "auth": true, "actions": [["dispatchClick", "#orbField a.worldOrb[data-origin=\"https://amazonprimea.com\"]"], ["wait", 900]], "expect": { "dialog": true, "hasClass": [["#projCard", "has-art"]], "visible": ["#cardArt", "#cardFaces", "#cardBuildersFig", "#cardShippedFig", "#cardOpen", "#cardHall"], "text": [["#cardBuilders", "11"], ["#cardShipped", "0"], ["#cardRel", "you build here"], ["#cardRuns", "amazonprimea.com"], ["#cardFaces .facesMore", "+6"]], "count": [["#cardFaces img", 5]], "href": [["#cardHall", "https://builders.amazonprimea.com/"]] } },
42
43
  "card-noart": { "auth": true, "actions": [["dispatchClick", "#orbField a.worldOrb[data-origin=\"https://emersonian-circles.cloudbongos.com\"]"], ["wait", 600]], "expect": { "dialog": true, "hasClass": [["#projCard", "has-art", false]], "hidden": ["#cardArt", "#cardFaces", "#cardHall"], "visible": ["#cardBuildersFig", "#cardShippedFig", "#cardOpen"], "text": [["#cardShipped", "30+"], ["#cardRel", "you build here"]] } },
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.669",
3
+ "version": "1.19.671",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.669",
9
+ "version": "1.19.671",
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.669",
3
+ "version": "1.19.671",
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",
package/src/module-api.js CHANGED
@@ -71,7 +71,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
71
71
  // there. scripts/gds/bump-version.js still rewrites the literal below; it appends
72
72
  // the entry to that file. Look for a version's history there, not here.
73
73
  // ---------------------------------------------------------------------------
74
- const CORE_VERSION = '1.19.669'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
74
+ const CORE_VERSION = '1.19.671'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
75
75
 
76
76
  // A namespaced logger so a module's log lines are attributable + consistent.
77
77
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
package/tests/onboard.mjs CHANGED
@@ -41,10 +41,28 @@ test('the OPTIONAL project-detail step sits in the canonical sequence, and optio
41
41
  assert.equal(step.optional, true, 'the flag is what tells a form it may be walked past');
42
42
  assert.equal(step.kind, 'auto',
43
43
  "'auto' like name-address: the FORM collects it, so a done panel of outstanding human steps must not list it");
44
- // one optional step keeps the flag meaningful; a second would need its own rule
45
- assert.deepEqual(plan.filter((s) => s.optional).map((s) => s.key), ['project-detail']);
46
- // and the adopt branch carries it identically the sequence branches only at scaffold
47
- assert.ok(ob.onboardPlan('adopt').some((s) => s.key === 'project-detail' && s.optional === true));
44
+ // THE RULE the second optional step needed (task 1002350). `optional` is not a
45
+ // general "nice to have" flag — it marks a CREATION-FORM answer that (a) rides the
46
+ // same POST /provisioning/instances body, (b) has a DEFINED meaning when omitted
47
+ // rather than an absent one, and (c) has a documented way back afterwards. Both
48
+ // members qualify: project-detail omitted leaves the project dark-matter until a
49
+ // description lands (PATCH …/detail is the way back, ADR 0182 D4), and
50
+ // project-modules omitted resolves the declared type's starter bundle on read
51
+ // (ADR 0240; modules are addable from the project's own page). A step that fails
52
+ // any of the three does not get this flag — which is why steps 12 and 13 of the
53
+ // skill (visibility, invite) are not in this plan at all: they run after the
54
+ // terminal step and belong to the done panel (ADR 0249), not the rail.
55
+ assert.deepEqual(plan.filter((s) => s.optional).map((s) => s.key), ['project-detail', 'project-modules']);
56
+ // the picker follows the interview because it READS its answer: the declared
57
+ // `type` keys the bundle the picker preselects (ADR 0237), so the order is a
58
+ // dependency, not a preference.
59
+ assert.equal(keys.indexOf('project-modules'), keys.indexOf('project-detail') + 1,
60
+ 'the module picker follows the step whose answer decides what it preselects');
61
+ // and the adopt branch carries BOTH identically — the sequence branches only at scaffold
62
+ for (const key of ['project-detail', 'project-modules']) {
63
+ assert.ok(ob.onboardPlan('adopt').some((s) => s.key === key && s.optional === true),
64
+ `${key}: adopt carries it too`);
65
+ }
48
66
  });
49
67
 
50
68
  test('every form OFFERS the optional step and names what skipping costs — skill / CLI / wizard / route (task 1002334)', () => {
@@ -691,7 +691,9 @@ test('the done panel re-learns the GitHub-App leg from the instance read, yieldi
691
691
  const seedAt = sp.indexOf('var ga = data.github_app;');
692
692
  const statusAt = sp.indexOf("if (inst && inst.status !== live.instStatus)");
693
693
  assert.ok(seedAt > 0 && seedAt < statusAt, 'the manifest seed lands before the status re-render, so one renderSteps() paints both');
694
- assert.match(fn('enter'), /if \(created\.manifest\) \{ live\.manifest = created\.manifest; live\.appSlug = created\.app_slug \|\| null; \}/, 'the sessionStorage bridge stays as the pre-first-tick seed');
694
+ /* the bridge moved from enter() to enterCreated() when task 1003503 split the
695
+ created-record branch out — same seed, same place in the same flow */
696
+ assert.match(fn('enterCreated'), /if \(created\.manifest\) \{ live\.manifest = created\.manifest; live\.appSlug = created\.app_slug \|\| null; \}/, 'the sessionStorage bridge stays as the pre-first-tick seed');
695
697
  });
696
698
 
697
699
  /* ── a settings push is named on both surfaces, by state, with no duration promise (task 1003140) ── */
@@ -0,0 +1,216 @@
1
+ // tests/wizard_intent_resume.mjs — "New project" means a NEW project (task 1003503).
2
+ //
3
+ // The hub's create wizard stores the instance it just stood up (`cb-created-instance`),
4
+ // and enter() resumed that record's done panel on EVERY arrival. Right mid-standup — the
5
+ // watch continues across a reload — but once the standup finished, the "New project" nav
6
+ // kept landing on the old checklist for the rest of the tab session. The only escape was
7
+ // the done panel's own "Start another project" button.
8
+ //
9
+ // Owner call (2026-09-11, the two options the task offered): intent-driven resume.
10
+ // • focus === false — a reload, Back, or the boot's identity settle. The SAME visit
11
+ // continuing, so the done panel and its human-steps checklist come back untouched.
12
+ // • focus === true — a deliberate nav click. An ask for ANOTHER project: a fresh
13
+ // step 1, with the standup one click away on a "still watching …" link.
14
+ // The record is never cleared by this, so nothing is lost either way — which is what
15
+ // ruled out the task's other option (drop the record at a terminal state).
16
+ //
17
+ // The reset fires only when there is a SECOND DRAFT to lose, and the draft's own step is
18
+ // what says so. A landed create clears DRAFT_KEY — but show(8) then persists the done
19
+ // panel's position straight back into it, so a plain "is there a draft?" test is always
20
+ // yes and would skip the reset forever, leaving the finished project's name sitting in
21
+ // the fresh form. (That is exactly what the first cut of this change shipped into a
22
+ // render: "still watching Mercury" above a name field still reading "Mercury".) Only
23
+ // steps 1-7 are wizard steps; anything else is the done panel's own bookkeeping.
24
+ //
25
+ // Harness: there is no jsdom in this repo, so this follows tests/wizard_draft_resume.mjs
26
+ // and tests/project_door_ui.mjs — slice the real region out of the page's INLINE script
27
+ // and run it in a vm under a minimal DOM stub. Both slice boundaries are asserted, so a
28
+ // rename fails loudly instead of leaving this file quietly testing nothing.
29
+ //
30
+ // projects.html is CRLF on a Windows checkout, so this file normalises at read time the
31
+ // way tests/projects_hub_pre_uat.mjs does — a vm cannot run a slice with stray \r in it.
32
+ //
33
+ // Run: node --test --test-reporter=tap tests/wizard_intent_resume.mjs
34
+
35
+ import assert from 'node:assert/strict';
36
+ import { test } from 'node:test';
37
+ import fs from 'node:fs';
38
+ import path from 'node:path';
39
+ import vm from 'node:vm';
40
+ import { fileURLToPath } from 'node:url';
41
+
42
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
43
+ const LANDING = path.join(ROOT, 'modules', 'public-landing', 'public');
44
+ const HUB = fs.readFileSync(path.join(LANDING, 'projects.html'), 'utf8').replace(/\r\n/g, '\n');
45
+ const COSMOS = fs.readFileSync(path.join(LANDING, 'assets', 'cosmos.css'), 'utf8').replace(/\r\n/g, '\n');
46
+ const SCRIPT = HUB.slice(HUB.lastIndexOf('<script>'), HUB.lastIndexOf('</script>'));
47
+
48
+ // ── the two slices ───────────────────────────────────────────────────────────────
49
+ // Each function closes on the first `}` at the wizard IIFE's own four-space indent.
50
+ const ENTER_RE = /function enterCreated\(created, focus\) \{[\s\S]*?\n {4}\}/;
51
+ const OFFER_RE = /function paintResumeOffer\(created\) \{[\s\S]*?\n {4}\}/;
52
+ const enterSlice = (SCRIPT.match(ENTER_RE) || [null])[0];
53
+ const offerSlice = (SCRIPT.match(OFFER_RE) || [null])[0];
54
+
55
+ test('both slices are still findable (a rename must fail loudly, not silently pass)', () => {
56
+ assert.ok(enterSlice, 'enterCreated() was not found in projects.html');
57
+ assert.ok(offerSlice, 'paintResumeOffer() was not found in projects.html');
58
+ assert.match(enterSlice, /if \(focus && !resumeIntent\) \{/, 'the intent branch is part of the slice');
59
+ assert.match(enterSlice, /startProgress\(created\.id\);/, 'so is the resume branch it guards');
60
+ assert.match(enterSlice, /draft\.step >= 1 && draft\.step <= 7/,
61
+ 'the STORED draft\'s step decides, not state.step — state.step is 8 for the whole life of a finished standup');
62
+ assert.match(SCRIPT, /if \(created && created\.id\) \{ enterCreated\(created, !!focus\); return; \}/,
63
+ 'enter() still hands the created record straight to it');
64
+ });
65
+
66
+ // A faithful miniature: the stubs record what enterCreated ASKS FOR, which is the whole
67
+ // contract — which panel is shown, whether the answers were reset, and what the offer
68
+ // above step 1 was told to name.
69
+ function runEnter({ focus, resumeIntent = false, draft = null, record }) {
70
+ const store = new Map();
71
+ if (draft) store.set('draft', JSON.stringify(draft));
72
+ const calls = { show: [], resets: 0, offer: [], done: 0, progress: [], polls: 0, focused: 0 };
73
+ // state.step is the DONE step for the whole life of a finished standup — the value
74
+ // a draft-blind reading would have trusted
75
+ const state = { step: draft ? draft.step : 8, name: 'Mercury', slug: 'mercury', domain: '', mode: 'greenfield', noAddress: false, modules: null };
76
+ const sandbox = {
77
+ record, focus, resumeIntent, state,
78
+ DRAFT_KEY: 'draft',
79
+ live: { manifest: null, appSlug: null },
80
+ manifestState: null,
81
+ manifestPending: false,
82
+ sessionStorage: { getItem: (k) => (store.has(k) ? store.get(k) : null) },
83
+ resetForNewProject() { calls.resets += 1; state.step = 1; state.name = ''; state.slug = ''; },
84
+ paintResumeOffer(c) { calls.offer.push(c ? (c.name || c.slug) : null); },
85
+ show(step, moveFocus) { state.step = step; calls.show.push([step, moveFocus]); },
86
+ renderDone() { calls.done += 1; },
87
+ startProgress(id) { calls.progress.push(id); },
88
+ resumePolls() { calls.polls += 1; },
89
+ $: () => ({ focus() { calls.focused += 1; } }),
90
+ };
91
+ sandbox.globalThis = sandbox;
92
+ vm.createContext(sandbox);
93
+ vm.runInContext(`${enterSlice}\nenterCreated(record, focus);`, sandbox);
94
+ return { calls, state, sandbox };
95
+ }
96
+
97
+ const REC = { id: 'inst-1', name: 'Acme', slug: 'acme', domain: 'acme.example', mode: 'greenfield' };
98
+
99
+ test('a reload / Back / identity settle (focus false) still resumes the done panel, watch and all', () => {
100
+ const { calls, state } = runEnter({ focus: false, record: REC });
101
+ assert.deepEqual(calls.show, [[8, false]], 'the done panel, and no focus steal on a non-deliberate arrival');
102
+ assert.equal(calls.resets, 0, 'nothing is reset — this is the same standup continuing');
103
+ assert.equal(calls.done, 1, 'the checklist is re-rendered');
104
+ assert.deepEqual(calls.progress, ['inst-1'], 'the progress watch is restarted from the record');
105
+ assert.equal(calls.polls, 1, 'and the parked manifest poll is resumed');
106
+ assert.equal(state.name, 'Acme', 'the record re-seeds the state the done panel reads');
107
+ assert.deepEqual(calls.offer, [null], 'the offer is down — the done panel is on stage, not step 1');
108
+ });
109
+
110
+ test('a deliberate "New project" click after a finished standup opens a FRESH step 1', () => {
111
+ const { calls, state } = runEnter({ focus: true, record: REC });
112
+ assert.equal(calls.done, 0, 'the previous project\'s done panel is not painted');
113
+ assert.equal(calls.progress.length, 0, 'and its watch is not restarted');
114
+ assert.equal(calls.resets, 1, 'with no second draft to lose, the wizard forgets the finished project');
115
+ assert.deepEqual(calls.show, [[1, false]], 'step 1, and step 1 does not take the heading focus');
116
+ assert.equal(calls.focused, 1, 'the name field does — the deliberate-nav rule enter() already applies');
117
+ assert.equal(state.name, '', 'the old name is gone from the form');
118
+ });
119
+
120
+ test('…and the standup stays one click away, named, on the resume link', () => {
121
+ const { calls } = runEnter({ focus: true, record: REC });
122
+ assert.deepEqual(calls.offer, ['Acme'], 'the offer names the project the record holds');
123
+ const noName = runEnter({ focus: true, record: { id: 'inst-2', slug: 'beta' } });
124
+ assert.deepEqual(noName.calls.offer, ['beta'], 'falling back to the handle when the record carries no name');
125
+ });
126
+
127
+ test('the resume link gets the done panel back — the record was never cleared', () => {
128
+ const { calls, sandbox } = runEnter({ focus: true, resumeIntent: true, record: REC });
129
+ assert.deepEqual(calls.show, [[8, true]], 'a deliberate resume moves focus to the done panel');
130
+ assert.deepEqual(calls.progress, ['inst-1'], 'the watch comes back with it');
131
+ assert.equal(sandbox.resumeIntent, false, 'the flag is ONE-SHOT — the next plain nav click must mean "new" again');
132
+ assert.deepEqual(calls.offer, [null], 'and the offer comes down behind it');
133
+ });
134
+
135
+ test('a second draft already under way is NOT wiped by navigating back to the wizard', () => {
136
+ // The regression this guards: with the reset unconditional, typing a name, stepping
137
+ // away to "Your projects" and clicking "New project" again would blank the draft —
138
+ // trading the reported bug for a worse one.
139
+ const { calls, state } = runEnter({ focus: true, record: REC, draft: { step: 5 } });
140
+ assert.equal(calls.resets, 0, 'a live draft is never reset');
141
+ assert.deepEqual(calls.show, [[5, true]], 'the second draft resumes where it was left, heading focused');
142
+ assert.equal(state.step, 5);
143
+ assert.deepEqual(calls.offer, ['Acme'], 'the offer stays up beside it');
144
+ });
145
+
146
+ test('the done panel\'s own saved step is NOT a second draft — it must not block the reset', () => {
147
+ // Caught in a render, not in a unit test: show(8) calls saveDraft(), so DRAFT_KEY is
148
+ // non-empty again the moment a create lands. Reading "is there a draft?" left the
149
+ // finished project's name in the fresh form, under a line offering to resume it.
150
+ const { calls, state } = runEnter({ focus: true, record: REC, draft: { step: 8, name: 'Mercury' } });
151
+ assert.equal(calls.resets, 1, 'the done step is bookkeeping, not a project being typed');
152
+ assert.deepEqual(calls.show, [[1, false]], 'out-of-range steps land on the name step');
153
+ assert.equal(state.name, '', 'and the finished project\'s name does not survive into it');
154
+ assert.equal(calls.done, 0, 'never back onto the done panel');
155
+ });
156
+
157
+ test('a draft whose step is missing or junk resets too — only 1-7 preserves anything', () => {
158
+ for (const draft of [{ step: 0 }, { step: 9 }, { name: 'half-typed' }]) {
159
+ const { calls } = runEnter({ focus: true, record: REC, draft });
160
+ assert.equal(calls.resets, 1, `step ${JSON.stringify(draft.step)} is not a wizard step`);
161
+ assert.deepEqual(calls.show, [[1, false]]);
162
+ }
163
+ });
164
+
165
+ // ── the offer itself ─────────────────────────────────────────────────────────────
166
+ test('paintResumeOffer names the project by textContent and hides on null (no live DOM from strings)', () => {
167
+ const els = { wizResume: { hidden: true }, wizResumeName: { textContent: '' } };
168
+ const sandbox = { $: (id) => els[id] };
169
+ sandbox.globalThis = sandbox;
170
+ vm.createContext(sandbox);
171
+ vm.runInContext(`${offerSlice}
172
+ paintResumeOffer({ id: 'i', name: 'Acme' });
173
+ var shown = { hidden: $('wizResume').hidden, name: $('wizResumeName').textContent };
174
+ paintResumeOffer(null);
175
+ var hid = $('wizResume').hidden;`, sandbox);
176
+ assert.equal(sandbox.shown.hidden, false, 'a record shows the line');
177
+ assert.equal(sandbox.shown.name, 'Acme', 'written as text, never as markup');
178
+ assert.equal(sandbox.hid, true, 'null takes it down');
179
+ assert.doesNotMatch(offerSlice, /innerHTML/, 'this surface builds no live DOM from strings');
180
+ });
181
+
182
+ test('the offer ships as real markup inside step 1, hidden until there is something to resume', () => {
183
+ const panel = HUB.slice(HUB.indexOf('id="panel-name"'), HUB.indexOf('id="panel-type"'));
184
+ assert.match(panel, /<p class="resumeOffer" id="wizResume" hidden>still watching <strong id="wizResumeName">your project<\/strong> — <a href="#" id="wizResumeGo">resume it<\/a><\/p>/,
185
+ 'baked into the panel (C7), carrying its own hidden state');
186
+ assert.ok(panel.indexOf('id="wizResume"') < panel.indexOf('<form id="newForm"'),
187
+ 'it sits above the name form, where a visitor reads it before typing');
188
+ assert.match(SCRIPT, /\$\('wizResumeGo'\)\.addEventListener\('click', function \(ev\) \{\n\s*ev\.preventDefault\(\);\n\s*resumeIntent = true;\n\s*enter\(true\);/,
189
+ 'the link raises the one-shot intent and re-enters, rather than href-navigating');
190
+ });
191
+
192
+ test('the offer clears the 24px hit floor as a box, like every other link on this panel', () => {
193
+ assert.match(COSMOS, /#view-new \.resumeOffer a\{display:inline-block;padding:3px 0;min-height:24px;/,
194
+ 'an inline link\'s rect is its content box — the floor needs the box');
195
+ assert.match(COSMOS, /#view-new \.resumeOffer\{[^}]*color:var\(--ink-soft\)/, 'quiet ink: this is an offer, not a banner');
196
+ });
197
+
198
+ // ── the reset the two doors share ────────────────────────────────────────────────
199
+ test('"Start another project" is the deliberate goodbye: the record goes, and the offer with it', () => {
200
+ const again = SCRIPT.slice(SCRIPT.indexOf('function wireAgain()'), SCRIPT.indexOf('function createdRecord()'));
201
+ assert.match(again, /resetForNewProject\(\);/, 'it shares the reset with the intent-driven start');
202
+ assert.match(again, /sessionStorage\.removeItem\(CREATED_KEY\)/, 'and it alone drops the record');
203
+ assert.match(again, /paintResumeOffer\(null\)/, 'so the offer must not survive it');
204
+ assert.doesNotMatch(enterSlice, /removeItem\(CREATED_KEY\)/,
205
+ 'the intent-driven path must NEVER clear the record — the link is the whole point');
206
+ });
207
+
208
+ test('the reset re-declares every key the initial state declares (modules is the one that bites)', () => {
209
+ const initial = SCRIPT.match(/var state = \{ step: 0,([^}]*)\}/);
210
+ const reset = SCRIPT.match(/function resetForNewProject\(\)[\s\S]*?state = \{ step: 1,([^}]*)\}/);
211
+ assert.ok(initial && reset, 'both state literals must be findable');
212
+ const keys = (s) => [...s.matchAll(/(\w+):/g)].map((m) => m[1]).sort();
213
+ assert.deepEqual(keys(reset[1]), keys(initial[1]),
214
+ 'a key left out reads as undefined, not as its sentinel — pickedModules() called .slice() on it and the second project\'s Modules step threw');
215
+ assert.match(reset[1], /modules: null/, 'null is the picker\'s "nothing said" answer (task 1002339)');
216
+ });