@bongos/core 1.19.349 → 1.19.351

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 (43) hide show
  1. package/.bongos-core.json +82 -42
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +8 -0
  4. package/clients/bongos-client/index.cjs +8 -0
  5. package/clients/bongos-client/index.d.ts +11 -0
  6. package/clients/bongos-client/index.mjs +8 -0
  7. package/docs/adr/0184-a-task-visual-inherits-its-summary-audience.md +49 -0
  8. package/docs/adr/README.md +1 -0
  9. package/docs/api/openapi.json +208 -3
  10. package/docs/api-reference.md +10 -2
  11. package/docs/architecture.md +6 -1
  12. package/docs/copy-inventory.md +39 -39
  13. package/docs/copy-registry.json +49 -49
  14. package/docs/file-map.md +2 -1
  15. package/migrations/core_223_task_visual.sql +33 -0
  16. package/modules/dev-box/app/src/vendor/bongos-client.cjs +8 -0
  17. package/modules/hall-ui/public/hall-kit.js +33 -0
  18. package/modules/hall-ui/public/hall-render.js +1 -0
  19. package/modules/hall-ui/public/profile.html +4 -1
  20. package/modules/hall-ui/public/profile.js +7 -1
  21. package/modules/hall-ui/public/style.css +29 -0
  22. package/modules/hall-ui/public/task.js +30 -0
  23. package/modules/lifecycle/db-analytics.js +3 -1
  24. package/modules/lifecycle/module.json +2 -1
  25. package/modules/lifecycle/routes/visuals.js +216 -0
  26. package/modules/lifecycle/task-visual-db.js +46 -0
  27. package/modules/lifecycle/task-visuals.js +270 -0
  28. package/modules/status-ui/public/status.js +33 -0
  29. package/modules/status-ui/public/style.css +14 -0
  30. package/package-lock.json +2 -2
  31. package/package.json +1 -1
  32. package/public-landing/projects.html +30 -10
  33. package/scripts/gds/ship-flow.js +11 -1
  34. package/scripts/gds/ship-visual.js +68 -0
  35. package/scripts/gds/task.js +62 -0
  36. package/src/bongos/routes/public.js +8 -0
  37. package/src/bongos/routes.js +5 -1
  38. package/src/module-api.js +5 -1
  39. package/tests/projects_hub.mjs +5 -3
  40. package/tests/projects_hub_pre_uat.mjs +41 -0
  41. package/tests/public_landing_projects.mjs +1 -1
  42. package/tests/task_visual_route_gate.mjs +151 -0
  43. package/tests/task_visuals.mjs +200 -0
@@ -653,7 +653,7 @@ summary{min-height:24px;padding:3px 0;}
653
653
  explanation, not another platform's task tracker. -->
654
654
  <details class="finePrint" id="mRepoVisWhy">
655
655
  <summary>Why can’t it be private yet?</summary>
656
- <p>The platform used to deploy every project by reading its code from GitHub with a shared read-only credential — and private repositories don’t admit it. New standups fix this: a private repository now gets the project’s own read-only deploy key, wired automatically<!--apex-only:start--> — shipped as <a href="{{buildersOrigin}}#/task/1002735" target="_blank" rel="noopener">task 1002735</a> in the builders’ hall<!--apex-only:end-->. A project set up before that still reads with the shared credential, so flipping it private would break its next deploy — the switch stays off until existing projects are re-wired.</p>
656
+ <p>This project’s deploys read its code from GitHub with an older key that only works on public repositories — so making the repository private would break the next deploy. New standups fix this: a private repository now gets the project’s own read-only deploy key, wired automatically<!--apex-only:start--> — shipped as <a href="{{buildersOrigin}}#/task/1002735" target="_blank" rel="noopener">task 1002735</a> in the builders’ hall<!--apex-only:end-->. A project set up before that still reads with the older key, so the switch stays off until existing projects are re-wired.</p>
657
657
  </details>
658
658
  </div>
659
659
 
@@ -1152,8 +1152,15 @@ summary{min-height:24px;padding:3px 0;}
1152
1152
  if (a.dataset.nav === 'home') return; /* a real navigation — leave it alone */
1153
1153
  a.addEventListener('click', function (ev) { ev.preventDefault(); setView(a.dataset.nav, true); });
1154
1154
  });
1155
- document.querySelectorAll('[data-nav-to]').forEach(function (a) {
1156
- a.addEventListener('click', function (ev) { ev.preventDefault(); setView(a.dataset.navTo, true); });
1155
+ /* delegated, not swept: a data-nav-to link injected into a card or the manage
1156
+ page after boot (addressRow, renderDelete) would never be upgraded by a
1157
+ one-shot querySelectorAll — it would cost a whole page load. A modified
1158
+ click keeps its browser meaning, the same contract a[data-manage] has. */
1159
+ document.addEventListener('click', function (ev) {
1160
+ var a = ev.target.closest && ev.target.closest('[data-nav-to]');
1161
+ if (!a || !isPlainClick(ev)) return;
1162
+ ev.preventDefault();
1163
+ setView(a.dataset.navTo, true);
1157
1164
  });
1158
1165
  /* Back / Forward land on the view the URL names — viewFromUrl() reads the
1159
1166
  manage id too, so a manage entry restores its project */
@@ -3125,7 +3132,7 @@ summary{min-height:24px;padding:3px 0;}
3125
3132
  '<p>GitHub → <em>your account’s</em> Settings → Developer settings → OAuth Apps → New OAuth App. (Developer settings live under your account, not the repo.)</p>' +
3126
3133
  '<div class="kv"><span class="kk">Homepage URL</span><code>' + esc(oa.homepageUrl) + '</code>' + copyBtn(oa.homepageUrl) + '</div>' +
3127
3134
  '<div class="kv"><span class="kk">Callback URL</span><code>' + esc(oa.callbackUrl) + '</code>' + copyBtn(oa.callbackUrl) + '</div>' +
3128
- '<p>The generated credentials are placed on the platform’s control plane by an operator — that part isn’t yours to run (the one-click path above does it all automatically). It’s done when <code style="font-family:var(--font-mono)">' + esc(oa.homepageUrl) + '/healthz</code> reports <code style="font-family:var(--font-mono)">auth_configured: true</code>.</p></details>';
3135
+ '<p>The platform team places the generated credentials; this step ticks itself once they land.</p></details>';
3129
3136
  }
3130
3137
 
3131
3138
  /* oauth-app — the ADR 0133 one-click GitHub-App manifest flow is the primary path
@@ -3176,10 +3183,10 @@ summary{min-height:24px;padding:3px 0;}
3176
3183
  the box. Web sign-in works either way; the CLI also has a web-token fallback. */
3177
3184
  function renderStepDeviceFlow() {
3178
3185
  if (live.manifest !== 'done') {
3179
- return '<li class="slocked" data-onboard-step="device-flow"><b>Enable Device Flow — one checkbox</b><p>Unlocks once GitHub sign-in above is wired. It lets builders sign in from a terminal (<code style="font-family:var(--font-mono)">bongos setup</code>); web sign-in works without it.</p></li>';
3186
+ return '<li class="slocked" data-onboard-step="device-flow"><b>Let builders sign in from a command line — one checkbox</b><p>Unlocks once GitHub sign-in above is wired. It lets builders sign in from a terminal — the <code style="font-family:var(--font-mono)">bongos setup</code> command builders run on their own machine; web sign-in works without it.</p></li>';
3180
3187
  }
3181
3188
  var appUrl = live.appSlug ? 'https://github.com/settings/apps/' + esc(live.appSlug) : null;
3182
- return '<li data-onboard-step="device-flow"><b>Enable Device Flow — one checkbox</b><p>' +
3189
+ return '<li data-onboard-step="device-flow"><b>Let builders sign in from a command line — one checkbox</b><p>' +
3183
3190
  (appUrl
3184
3191
  ? 'Open <a href="' + appUrl + '" target="_blank" rel="noopener">your app’s settings</a>, check <strong>Enable Device Flow</strong>, and save. '
3185
3192
  : 'GitHub → Settings → Developer settings → GitHub Apps → your app → check <strong>Enable Device Flow</strong> → save. ') +
@@ -3957,6 +3964,11 @@ summary{min-height:24px;padding:3px 0;}
3957
3964
  address is tracked separately (task 1002897); until then "reserved" is
3958
3965
  both the honest word and the accurate one, since a revive re-uses exactly
3959
3966
  this hostname. */
3967
+ /* pre-standup: at 'requested' nothing has been built, so the shape arms
3968
+ below would describe a server and a database that never existed. error
3969
+ and provisioning keep the built-world text on purpose — a run that failed
3970
+ partway may already have a database, a port and a DNS record. */
3971
+ if (inst && inst.status === 'requested') return 'Nothing has been stood up yet — this cancels the standup. Anything already on GitHub is untouched, and the name and web address stay reserved for this project.';
3960
3972
  return inst && inst.hosting_shape === 'dedicated'
3961
3973
  ? 'This project has a machine to itself, so the machine goes away and its database goes with it. Your repository is untouched. Its web address stays reserved for this project and shows an error page until you bring it back. Nothing here erases a project.'
3962
3974
  : 'The server goes away. Your repository is untouched. The database is kept, so bringing it back later restores its memory. Its web address stays reserved for this project and shows an error page until you bring it back. Nothing here erases a project.';
@@ -3989,7 +4001,7 @@ summary{min-height:24px;padding:3px 0;}
3989
4001
  return 'Address: <code>' + esc(inst.domain) + '</code> — it answers there once the instance is live.';
3990
4002
  }
3991
4003
  /* the HONEST no-address state: sign-in on the instance needs a real domain */
3992
- return '<span class="none">No address yet</span> — sign-in on this project can’t be set up until it has one.';
4004
+ return '<span class="none">No address yet</span> — sign-in on this project can’t be set up until it has one. <a href="?view=mine" data-nav-to="mine">Give it an address from your projects</a>.';
3993
4005
  }
3994
4006
 
3995
4007
  /* ── the bring-your-own-domain fold (owner decision 2, 2026-08-24). A live
@@ -4431,11 +4443,18 @@ summary{min-height:24px;padding:3px 0;}
4431
4443
  if (m === 'handled' || m === 'stale') return;
4432
4444
  $('mLoading').hidden = true;
4433
4445
  var a = $('mAlert');
4434
- a.textContent = 'Couldn’t read this project right now — refresh in a moment.';
4446
+ a.innerHTML = 'Couldn’t read this project right now. <button type="button" class="btn small ghost" id="mAlertRetry">Try again</button>';
4435
4447
  a.classList.add('on');
4436
4448
  });
4437
4449
  }
4438
4450
 
4451
+ /* the alert's own way out (task 1003245): delegated, because the catch above
4452
+ rewrites #mAlert's innerHTML on every failure — a bound button would not
4453
+ survive the next write. A loud re-read: the founder asked. */
4454
+ $('mAlert').addEventListener('click', function (ev) {
4455
+ if (ev.target.closest && ev.target.closest('#mAlertRetry')) loadManage();
4456
+ });
4457
+
4439
4458
  function manageChipTick() {
4440
4459
  if (document.hidden || currentView !== 'manage' || !manageInst) return;
4441
4460
  renderManage(manageInst); /* zero requests — re-dates "checked Xm ago" */
@@ -4475,7 +4494,7 @@ summary{min-height:24px;padding:3px 0;}
4475
4494
  } else {
4476
4495
  repoVisMsg(
4477
4496
  '<div class="addr">This repository is <b>public</b> — anyone can read its code.</div>' +
4478
- '<p class="finePrint">Making it private isn’t possible from here yet: this project’s deploys still read the repository with the shared credential, so flipping now would break the next deploy. The switch lands with the re-wiring fix.</p>');
4497
+ '<p class="finePrint">Making it private isn’t possible from here yet — see below for why.</p>');
4479
4498
  }
4480
4499
  }
4481
4500
 
@@ -4596,7 +4615,8 @@ summary{min-height:24px;padding:3px 0;}
4596
4615
  return;
4597
4616
  }
4598
4617
  /* a project that never finished standing up still reaches here — that is
4599
- the whole point of the widened gate; the consequence copy is the same */
4618
+ the whole point of the widened gate; teardownTruth() scopes the copy to
4619
+ its status */
4600
4620
  delMsg('<p class="plain">' + teardownTruth(inst) + '</p>' +
4601
4621
  '<div class="actions"><button type="button" class="btn small danger" data-del-ask>Tear it down</button></div>');
4602
4622
  }
@@ -15,6 +15,8 @@ const { scopeForModule, isModuleKey } = require('../../src/bongos/module-scope-m
15
15
  const { matchOne } = require('../../src/bongos/path-match');
16
16
  const { runShipGate } = require('./sandbox-stage');
17
17
  const { captureSurpriseLearning, promptSurprise } = require('./ship-surprise.js');
18
+ // task 1003109: the optional `--visual` leg of a ship (goal 1000072).
19
+ const shipVisual = require('./ship-visual.js');
18
20
  const { gitOk } = require('./ship-git.js');
19
21
  const { autoLinkifyChangedDocs, mergeMainIntoBranch, preflight } = require('./ship-preflight-steps.js');
20
22
  const { applyBaseOverride, computeChangedFiles, computeGraderBaseline, runSmokeAsync } = require('./ship-grade.js');
@@ -35,7 +37,7 @@ async function shipMain() {
35
37
  const args = process.argv.slice(2);
36
38
  const taskId = Number(args[0]);
37
39
  if (!Number.isFinite(taskId)) {
38
- console.error('usage: node scripts/gds/ship.js <task-id> (--notes-file <path|-> | --notes "...") (--summary-file <path|-> | --summary "...") [--surprise "text" | --surprise none | --no-surprise] [--no-merge] [--db-only] [--verified] [--allow-empty] [--allow-smoke-fail] [--base <ref>]');
40
+ console.error('usage: node scripts/gds/ship.js <task-id> (--notes-file <path|-> | --notes "...") (--summary-file <path|-> | --summary "...") [--visual <image>] [--visual-alt "caption"] [--surprise "text" | --surprise none | --no-surprise] [--no-merge] [--db-only] [--verified] [--allow-empty] [--allow-smoke-fail] [--base <ref>]');
39
41
  console.error(' prefer the -file forms for prose: a shell command-substitutes backticks in an inline value before node sees it (learning 1000197).');
40
42
  process.exit(2);
41
43
  }
@@ -94,6 +96,10 @@ async function shipMain() {
94
96
  console.log(`--base: grading ${res.sha.slice(0, 9)}...HEAD — the task's real committed diff (work already on main). Graded via the rank-open path; no --allow-empty needed.`);
95
97
  }
96
98
 
99
+ // task 1003109: screen --visual BEFORE any heavy work, so bad input aborts with
100
+ // the claim STILL ACTIVE (the --base posture above). null when none was asked for.
101
+ const visual = shipVisual.fromArgs(args);
102
+
97
103
  const me = noteServerReward(await (await cliClient()).me.getMe());
98
104
  if (!me.ok) {
99
105
  console.error('failed to load profile:', me.data);
@@ -456,6 +462,10 @@ async function shipMain() {
456
462
 
457
463
  const taskStatus = r.data.taskStatus || 'shipped (legacy response)';
458
464
  console.log(`Task #${r.data.taskId} is now: ${taskStatus}`);
465
+ // task 1003109: attach the optional visual now the claim has resolved (the upload
466
+ // gate is "the task's last claim holder" — still us). Best-effort: it prints its
467
+ // own warning, and a failed picture never fails a successful ship.
468
+ await shipVisual.upload({ taskId, visual, client: await cliClient(), errorLine: apiErrorLine });
459
469
  // Claim CONSUMED, task at 'completed' — a crash from here needs fatalRecoveryLines.
460
470
  markShipProgress('resolved', taskId);
461
471
 
@@ -0,0 +1,68 @@
1
+ 'use strict';
2
+
3
+ // scripts/gds/ship-visual.js — the `--visual` leg of a ship (task 1003109, goal
4
+ // 1000072 "Human legibility").
5
+ //
6
+ // A shipped task can carry an optional IMAGE alongside its value summary: a
7
+ // screenshot of the surface that changed, a before/after, a diagram — so a reader
8
+ // who does not read code can SEE what shipped instead of reading about it (owner
9
+ // idea 1000400). ship.js owns the ship; this file owns the picture.
10
+ //
11
+ // Its own file, not another 60 lines inside the 4,600-line ship.js, because the
12
+ // size ratchet (fitness-ratchets.js, task 1003129) is shrink-only — and because
13
+ // the two things really are separable: the ship must not care whether a visual
14
+ // came along.
15
+ //
16
+ // THE ONE RULE HERE: a visual never costs a builder their ship. Bad input is
17
+ // caught BEFORE the claim resolves (a usage error, claim intact); a failed upload
18
+ // happens AFTER it and is a warning, never a failure.
19
+
20
+ const path = require('node:path');
21
+ const { arg } = require('./cli-lib');
22
+ // Reading + screening is the SERVER's own lib, so a terminal refuses exactly what
23
+ // the upload route would refuse.
24
+ const taskVisuals = require('../../modules/lifecycle/task-visuals');
25
+
26
+ // Parse + screen `--visual <file> [--visual-alt "caption"]` from an argv slice.
27
+ // Returns null when no visual was asked for, or { buf, contentType, alt, name }.
28
+ // EXITS (code 2) on bad input: this runs before any heavy work, so a wrong path
29
+ // costs the builder a retyped command, not a claim. Pure enough to reason about;
30
+ // the exit is the deliberate CLI edge.
31
+ function fromArgs(args) {
32
+ const file = arg('--visual', args) || '';
33
+ if (!file) return null;
34
+ const read = taskVisuals.readImageFile(file);
35
+ if (!read.ok) {
36
+ console.error(`Ship aborted: --visual ${file} — ${read.message}`);
37
+ console.error('Fix the path (or drop --visual: a visual is always optional). No claim resolved, no credits awarded.');
38
+ process.exit(2);
39
+ }
40
+ const visual = { ...read, alt: arg('--visual-alt', args) || '', name: path.basename(file) };
41
+ console.log(`--visual: ${visual.name} (${visual.contentType}, ${Math.round(visual.buf.length / 1024)} KB) — uploaded after the claim resolves.`);
42
+ return visual;
43
+ }
44
+
45
+ // Upload the screened visual to the task. BEST-EFFORT BY DESIGN: called AFTER the
46
+ // claim has resolved, so a failure here must never fail the ship — the work
47
+ // landed, only the picture did not. Returns true when it stuck; prints a
48
+ // one-line, actionable warning and returns false when it did not.
49
+ async function upload({ taskId, visual, client, errorLine }) {
50
+ if (!visual) return false;
51
+ const r = await client.tasks.postTasksIdVisual({
52
+ id: taskId,
53
+ body: {
54
+ image_b64: visual.buf.toString('base64'),
55
+ content_type: visual.contentType,
56
+ ...(visual.alt ? { alt: visual.alt } : {}),
57
+ },
58
+ });
59
+ if (r.ok) {
60
+ console.log("Visual attached — it renders wherever this task's value summary does.");
61
+ return true;
62
+ }
63
+ console.warn(`Note: the visual did not upload (${errorLine(r.data, r.status)}). The ship is unaffected — re-attach any time with:`);
64
+ console.warn(` node scripts/gds/task.js visual ${taskId} <image> [--alt "caption"]`);
65
+ return false;
66
+ }
67
+
68
+ module.exports = { fromArgs, upload };
@@ -11,11 +11,16 @@
11
11
  // [--priority 3] [--version <id>] [--goal <id>] [--criteria C1,C2]
12
12
  // [--touches a/,b/] [--est-minutes N] [--credits N] [--ready] [--json]
13
13
  // bongos task show <id> [--json]
14
+ // bongos task visual <id> <image> [--alt "caption"] attach/replace the ship-time visual
15
+ // bongos task visual <id> --remove take it back off
14
16
  //
15
17
  // Task CREATION is Archon-gated SERVER-SIDE (creating work shapes scope, ADR 0016). This
16
18
  // CLI adds no authority — a lower rank gets a clear 403 message pointing at idea capture.
17
19
 
18
20
  const { cliClient, requireSession, arg, hasFlag } = require('./cli-lib');
21
+ // task 1003109: the ship-time VISUAL. The CLI screens the file with the SERVER's
22
+ // own constants, so what it accepts is exactly what the upload route accepts.
23
+ const taskVisuals = require('../../modules/lifecycle/task-visuals');
19
24
 
20
25
  // Valid enums (mirrors the server's task-classifier vocab; a friendly client-side check
21
26
  // so an obvious typo fails before the round-trip — the server stays authoritative).
@@ -77,6 +82,8 @@ function usage() {
77
82
  console.error(' [--priority 3] [--version <id>] [--goal <id>] [--criteria C1,C2]');
78
83
  console.error(' [--touches a/,b/] [--est-minutes N] [--credits N] [--ready] [--json]');
79
84
  console.error(' bongos task show <id> [--json]');
85
+ console.error(' bongos task visual <id> <image> [--alt "caption"] attach or replace the ship-time visual');
86
+ console.error(' bongos task visual <id> --remove remove it again');
80
87
  console.error(`\n kinds: ${VALID_KINDS.join(' | ')}`);
81
88
  console.error(` disciplines: ${VALID_DISCIPLINES.join(' | ')}`);
82
89
  }
@@ -149,6 +156,60 @@ async function cmdShow(args) {
149
156
  if (task.description) console.log(`\n${task.description}`);
150
157
  }
151
158
 
159
+
160
+ // ---- bongos task visual (task 1003109, goal 1000072) ------------------------
161
+ //
162
+ // A shipped task can carry a VISUAL alongside its value summary — a screenshot of
163
+ // the surface that changed, a before/after, a diagram — so a reader who does not
164
+ // read code can SEE what changed. `ship.js --visual` is the usual path; this is
165
+ // the after-the-fact one: attach the picture you forgot, replace the wrong one,
166
+ // or take it back off. Same server gate either way — the task's claim holder (or
167
+ // an Archon). Always optional: no task needs one.
168
+ async function cmdVisual(args) {
169
+ const id = Number(args[0]);
170
+ if (!Number.isInteger(id) || id <= 0) {
171
+ console.error('usage: bongos task visual <id> <image> [--alt "caption"]');
172
+ console.error(' bongos task visual <id> --remove');
173
+ process.exit(2);
174
+ }
175
+ const api = await cliClient();
176
+
177
+ if (hasFlag('--remove', args)) {
178
+ const r = await api.tasks.deleteTasksIdVisual({ id });
179
+ if (r.status === 404) { console.error(`task #${id} not found.`); process.exit(1); }
180
+ if (!r.ok) { console.error(`visual remove failed (${r.status}):`, r.data); process.exit(1); }
181
+ console.log(`✓ Task #${id} no longer carries a visual.`);
182
+ return;
183
+ }
184
+
185
+ // The image is POSITIONAL — `visual <id> <image>`. Deliberately not "the first
186
+ // arg that isn't a flag": with `--alt "a caption" shot.png` that heuristic
187
+ // picks up the caption as the filename.
188
+ const file = args[1] && !args[1].startsWith('--') ? args[1] : null;
189
+ if (!file) {
190
+ console.error('usage: bongos task visual <id> <image> [--alt "caption"]');
191
+ process.exit(2);
192
+ }
193
+ // Reading + screening is the SERVER's own lib (shared with ship.js --visual),
194
+ // so a terminal refuses exactly what the upload route would refuse.
195
+ const visual = taskVisuals.readImageFile(file);
196
+ if (!visual.ok) {
197
+ console.error(`${file} — ${visual.message}`);
198
+ process.exit(2);
199
+ }
200
+
201
+ const alt = arg('--alt', args) || '';
202
+ const r = await api.tasks.postTasksIdVisual({
203
+ id,
204
+ body: { image_b64: visual.buf.toString('base64'), content_type: visual.contentType, ...(alt ? { alt } : {}) },
205
+ });
206
+ if (r.status === 404) { console.error(`task #${id} not found.`); process.exit(1); }
207
+ if (r.status === 403) { console.error(`Only task #${id}'s claim holder (or an Archon) may attach its visual.`); process.exit(1); }
208
+ if (!r.ok) { console.error(`visual upload failed (${r.status}):`, r.data); process.exit(1); }
209
+ console.log(`✓ Task #${id} now carries a visual (${Math.round(visual.buf.length / 1024)} KB).`);
210
+ console.log(' It renders wherever this task\'s value summary does.');
211
+ }
212
+
152
213
  async function main() {
153
214
  const args = process.argv.slice(2);
154
215
  const sub = args[0];
@@ -156,6 +217,7 @@ async function main() {
156
217
  await requireSession();
157
218
  if (sub === 'new') return cmdNew(args.slice(1));
158
219
  if (sub === 'show') return cmdShow(args.slice(1));
220
+ if (sub === 'visual') return cmdVisual(args.slice(1));
159
221
  console.error(`task: unknown subcommand "${sub}"\n`); usage(); process.exit(2);
160
222
  }
161
223
 
@@ -43,6 +43,14 @@ function projectPublicTask(task) {
43
43
  payload.title = task.title;
44
44
  payload.value_summary = task.value_summary ?? null;
45
45
  payload.shipped_at = task.shipped_at ?? null;
46
+ // task 1003109: the ship-time VISUAL travels with the summary it restates —
47
+ // one claim, one audience, in both media. The extra `security_sensitive`
48
+ // narrowing is the serve route's own gate (task-visuals.isPubliclyViewable);
49
+ // publishing a URL this projection would then refuse to serve would be a
50
+ // broken image, so the two agree here.
51
+ const visualIsPublic = task.security_sensitive !== true;
52
+ payload.visual_url = visualIsPublic ? (task.visual_url ?? null) : null;
53
+ payload.visual_alt = visualIsPublic ? (task.visual_alt ?? null) : null;
46
54
  }
47
55
  return payload;
48
56
  }
@@ -86,7 +86,11 @@ function buildGdsRouter() {
86
86
  req.path === '/memory/sync' ||
87
87
  req.path === '/sessions' ||
88
88
  req.path === '/llm-cache/store' ||
89
- /^\/tasks\/\d+\/publish-branch$/.test(req.path)
89
+ /^\/tasks\/\d+\/publish-branch$/.test(req.path) ||
90
+ // task 1003109: the ship-time task visual — a base64 image up to ~5.3 MB
91
+ // encoded (4 MB decoded). Same dynamic-:id pattern as publish-branch above;
92
+ // modules/lifecycle/routes/task-visuals.js mounts its own 8 MB parser.
93
+ /^\/tasks\/\d+\/visual$/.test(req.path)
90
94
  ) return next();
91
95
  return globalJson(req, res, next);
92
96
  });
package/src/module-api.js CHANGED
@@ -1128,8 +1128,12 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
1128
1128
  // landed since 1.19.347 with no explicit bump. run 32802746363. (task 1002620)
1129
1129
  // 1.19.349 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1130
1130
  // landed since 1.19.348 with no explicit bump. run 32803276871. (task 1002620)
1131
+ // 1.19.350 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1132
+ // landed since 1.19.349 with no explicit bump. run 32810944115. (task 1002620)
1133
+ // 1.19.351 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1134
+ // landed since 1.19.350 with no explicit bump. run 32850120472. (task 1002620)
1131
1135
  // ---------------------------------------------------------------------------
1132
- const CORE_VERSION = '1.19.349'; // CI auto-patch carrier (ADR 0161); see changelog above
1136
+ const CORE_VERSION = '1.19.351'; // CI auto-patch carrier (ADR 0161); see changelog above
1133
1137
 
1134
1138
  // A namespaced logger so a module's log lines are attributable + consistent.
1135
1139
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -997,8 +997,10 @@ test('flip-to-private cannot travel from this page — only the public direction
997
997
  'no code path may send the private direction');
998
998
  // the refusal is SAID, with its reason, not silently missing — post-ADR-0176 the
999
999
  // reason is THIS project's shared-credential wiring, not a platform-wide inability
1000
- assert.match(SCRIPT, /would break the next deploy/,
1001
- 'the flip-to-private limitation is stated in plain words');
1000
+ assert.match(SCRIPT, /Making it private isn’t possible from here yet — see below for why\./,
1001
+ 'the refusal is SAID inline, and points at the fold that carries the reason');
1002
+ assert.match(HUB, /would break the next deploy/,
1003
+ 'the flip-to-private limitation is stated in plain words, in the fold');
1002
1004
  });
1003
1005
 
1004
1006
  test('the visibility shown is GitHub\'s answer, never an assumption', () => {
@@ -1033,7 +1035,7 @@ test('the refusal carries a LINK to the limitation, never prose alone (task 1003
1033
1035
  assert.match(HUB, /id="mRepoVisWhy"/, 'the why-fold is real markup beside the section body');
1034
1036
  const fold = (HUB.match(/<details[^>]*id="mRepoVisWhy"[\s\S]*?<\/details>/) || [''])[0];
1035
1037
  assert.ok(fold, 'the fold must be extractable');
1036
- assert.match(fold, /shared read-only credential/, 'the WHY is stated in plain words');
1038
+ assert.match(fold, /an older key that only works on public repositories/, 'the WHY is stated in plain words');
1037
1039
  assert.match(fold, /\{\{buildersOrigin\}\}#\/task\/1002735/,
1038
1040
  'the tracked fix is a real link, hall-origin server-stamped (never client-filled)');
1039
1041
  // apex-only: a child instance keeps the explanation, not another platform's tracker
@@ -363,6 +363,47 @@ test('card action results announce, nothing under the pointer is deleted, and fo
363
363
  assert.match(fn('focusCardControl'), /li\.querySelector\('\.actions a, \.actions button'\) \|\|\n\s*li\.querySelector\('details\.attachFold summary'\)/, 'nearest control as the fallback');
364
364
  });
365
365
 
366
+ /* ── copy follow-ups (task 1003245) ── */
367
+ test('teardown copy is state-aware: a queued project is told nothing was built', () => {
368
+ const truth = fn('teardownTruth');
369
+ assert.match(truth, /if \(inst && inst\.status === 'requested'\) return 'Nothing has been stood up yet — this cancels the standup\. Anything already on GitHub is untouched, and the name and web address stay reserved for this project\.';/);
370
+ const arm = truth.indexOf("inst.status === 'requested'");
371
+ const shape = truth.indexOf("hosting_shape === 'dedicated'");
372
+ assert.ok(arm > 0 && arm < shape, 'the pre-standup arm comes before the shape arms');
373
+ assert.doesNotMatch(truth, /status === 'error'|status === 'provisioning'/, 'error and provisioning keep today’s text — a failed run may already have built things');
374
+ });
375
+
376
+ test('founder voice on the Device Flow step and the manual OAuth fallback', () => {
377
+ const df = fn('renderStepDeviceFlow');
378
+ assert.equal((df.match(/<b>Let builders sign in from a command line — one checkbox<\/b>/g) || []).length, 2, 'both branches carry the outcome-first title');
379
+ assert.doesNotMatch(df, /<b>Enable Device Flow — one checkbox<\/b>/);
380
+ assert.match(df, /the <code style="font-family:var\(--font-mono\)">bongos setup<\/code> command builders run on their own machine/, 'the command is glossed, not bare-coded');
381
+ assert.doesNotMatch(df, /Optional/, 'not optional — a manifest-born App has Device Flow off');
382
+ assert.equal((df.match(/<strong>Enable Device Flow<\/strong>/g) || []).length, 2, 'GitHub’s own checkbox label stays verbatim in the instructions');
383
+ const manual = fn('manualOauthDetails');
384
+ assert.match(manual, /The platform team places the generated credentials; this step ticks itself once they land\.<\/p><\/details>/);
385
+ assert.doesNotMatch(manual, /auth_configured|healthz/, 'no JSON health check for a founder to perform');
386
+ });
387
+
388
+ test('repo visibility: the refusal is one line, the mechanism lives in the fold', () => {
389
+ const rv = fn('renderRepoVis');
390
+ assert.match(rv, /This repository is <b>public<\/b> — anyone can read its code\./, 'line one stays');
391
+ assert.match(rv, /Making it private isn’t possible from here yet — see below for why\./);
392
+ assert.doesNotMatch(rv, /shared credential/);
393
+ const fold = (HUB.match(/<details[^>]*id="mRepoVisWhy"[\s\S]*?<\/details>/) || [''])[0];
394
+ assert.match(fold, /an older key that only works on public repositories/);
395
+ assert.doesNotMatch(fold, /shared read-only credential/);
396
+ assert.match(fold, /<!--apex-only:start--> — shipped as <a href="\{\{buildersOrigin\}\}#\/task\/1002735"/, 'the hall task link stays inside its apex-only sentinel');
397
+ });
398
+
399
+ test('a failed manage read offers a retry; the no-address sentence offers a door', () => {
400
+ assert.match(SCRIPT, /a\.innerHTML = 'Couldn’t read this project right now\. <button type="button" class="btn small ghost" id="mAlertRetry">Try again<\/button>';/);
401
+ assert.match(SCRIPT, /\$\('mAlert'\)\.addEventListener\('click', function \(ev\) \{\n\s*if \(ev\.target\.closest && ev\.target\.closest\('#mAlertRetry'\)\) loadManage\(\);/, 'delegated — the catch rewrites the alert on every failure');
402
+ assert.match(fn('addressRow'), /No address yet<\/span> — sign-in on this project can’t be set up until it has one\. <a href="\?view=mine" data-nav-to="mine">Give it an address from your projects<\/a>\./);
403
+ assert.match(SCRIPT, /var a = ev\.target\.closest && ev\.target\.closest\('\[data-nav-to\]'\);\n\s*if \(!a \|\| !isPlainClick\(ev\)\) return;/, 'data-nav-to is delegated, so a link injected after boot switches views in place');
404
+ assert.doesNotMatch(SCRIPT, /querySelectorAll\('\[data-nav-to\]'\)/, 'the boot-time sweep is gone');
405
+ });
406
+
366
407
  /* ── accessibility follow-ups (task 1003246) ── */
367
408
  test('the done panel’s progress region is stable, change-only, and says each stage’s state', () => {
368
409
  assert.match(HUB, /<div id="liveProgress" role="status" aria-live="polite"><\/div>/, 'the live region is the static wrapper');
@@ -157,7 +157,7 @@ test('the repo-visibility section is served live, with the private direction ref
157
157
  // never shows another platform's tracker). The link itself is pinned inside
158
158
  // its sentinels by tests/projects_hub.mjs.
159
159
  assert.match(body, /id="mRepoVisWhy"/, 'the why-fold is served');
160
- assert.match(body, /shared read-only credential/, 'the explanation survives the strip');
160
+ assert.match(body, /an older key that only works on public repositories/, 'the explanation survives the strip');
161
161
  assert.doesNotMatch(body, /#\/task\/1002735/,
162
162
  'the apex-only task link is stripped from a standalone serve');
163
163
  });
@@ -0,0 +1,151 @@
1
+ // tests/task_visual_route_gate.mjs
2
+ //
3
+ // Route-level proof of the ONE rule behind ship-time task visuals (task 1003109,
4
+ // ADR 0184): a visual has no audience of its own — it inherits the audience of the
5
+ // value_summary it restates. Already-public work (shipped|confirmed, not
6
+ // security_sensitive) serves unauthenticated; EVERYTHING else falls through to
7
+ // auth.requireBuilder and never past it.
8
+ //
9
+ // The unit tests in tests/task_visuals.mjs pin the predicate. This pins the WIRING
10
+ // — that the handler actually consults it, in the right order, and fails closed on
11
+ // every miss. It runs the real Express router with the data layer and the auth gate
12
+ // stubbed at the module boundary; no DB, no network, no session.
13
+ //
14
+ // Run: node tests/task_visual_route_gate.mjs
15
+
16
+ import { strict as assert } from 'node:assert';
17
+ import { createRequire } from 'node:module';
18
+ import http from 'node:http';
19
+ import os from 'node:os';
20
+ import path from 'node:path';
21
+ import fs from 'node:fs/promises';
22
+ const require = createRequire(import.meta.url);
23
+
24
+ const TMP = path.join(os.tmpdir(), `otb-visualroute-${process.pid}`);
25
+ process.env.TASK_VISUALS_DIR = TMP;
26
+
27
+ const express = require('express');
28
+ const api = require('../src/module-api.js');
29
+ const visualDb = require('../modules/lifecycle/task-visual-db.js');
30
+ const visuals = require('../modules/lifecycle/task-visuals.js');
31
+
32
+ // ---- stubs at the module boundary -----------------------------------------
33
+ // The route reads `db.getTaskVisualAccess` and `auth.requireBuilder` off these
34
+ // module objects at CALL time, so replacing the properties is enough — no loader
35
+ // tricks, and the router under test is the real one.
36
+ let taskRow = null; // what the "DB" says about the task
37
+ let lookupThrows = false;
38
+ visualDb.getTaskVisualAccess = async () => {
39
+ if (lookupThrows) throw new Error('simulated DB outage');
40
+ return taskRow;
41
+ };
42
+ let authCalls = 0;
43
+ api.requireBuilder = (req, res) => {
44
+ authCalls++;
45
+ // Stand in for the real gate's deny path: it owns its own response, so reaching
46
+ // here means the request was NOT served publicly.
47
+ res.status(401).json({ error: 'unauthenticated' });
48
+ };
49
+
50
+ const buildRouter = require('../modules/lifecycle/routes/visuals.js');
51
+
52
+ // res.fail is composed at the kernel level in production; supply the same shape.
53
+ const app = express();
54
+ app.use((req, res, next) => {
55
+ res.fail = (code, status) => res.status(typeof status === 'number' ? status : (status && status.status) || 500).json({ error: code });
56
+ next();
57
+ });
58
+ app.use(buildRouter());
59
+
60
+ const server = http.createServer(app);
61
+ await new Promise((r) => server.listen(0, '127.0.0.1', r));
62
+ const base = `http://127.0.0.1:${server.address().port}`;
63
+
64
+ // A real stored file, so a successful gate actually streams bytes.
65
+ const PNG = Buffer.from('89504e470d0a1a0a0000000d49484452', 'hex');
66
+ const stored = await visuals.persistImageBuffer(PNG, { taskId: 4242, contentType: 'image/png', dir: TMP });
67
+ const NAME = stored.file;
68
+
69
+ let passed = 0, failed = 0;
70
+ function t(name, fn) {
71
+ return Promise.resolve().then(fn)
72
+ .then(() => { passed++; console.log(` PASS ${name}`); })
73
+ .catch((e) => { failed++; console.log(` FAIL ${name}\n ${e.message}`); });
74
+ }
75
+ async function get(name) {
76
+ authCalls = 0;
77
+ const res = await fetch(`${base}/task-visuals/${name}`);
78
+ return { status: res.status, authCalls, buf: Buffer.from(await res.arrayBuffer()), headers: res.headers };
79
+ }
80
+
81
+ console.log('Task-visual serve gate (task 1003109 / ADR 0184):');
82
+
83
+ await t('a SHIPPED task serves its visual to an unauthenticated caller', async () => {
84
+ taskRow = { id: 4242, status: 'shipped', security_sensitive: false };
85
+ const r = await get(NAME);
86
+ assert.equal(r.status, 200);
87
+ assert.equal(r.authCalls, 0, 'public work must not be sent through the auth gate');
88
+ assert.deepEqual(r.buf, PNG);
89
+ assert.equal(r.headers.get('content-type'), 'image/png');
90
+ assert.equal(r.headers.get('x-content-type-options'), 'nosniff');
91
+ assert.equal(r.headers.get('content-disposition'), 'inline');
92
+ assert.match(r.headers.get('cache-control'), /^public,/);
93
+ });
94
+
95
+ await t('a CONFIRMED task is public too — the same set as a public value_summary', async () => {
96
+ taskRow = { id: 4242, status: 'confirmed', security_sensitive: false };
97
+ assert.equal((await get(NAME)).status, 200);
98
+ });
99
+
100
+ await t('IN-FLIGHT work never serves publicly — it falls through to requireBuilder', async () => {
101
+ for (const status of ['backlog', 'ready', 'active', 'completed', 'abandoned']) {
102
+ taskRow = { id: 4242, status, security_sensitive: false };
103
+ const r = await get(NAME);
104
+ assert.equal(r.status, 401, status);
105
+ assert.equal(r.authCalls, 1, `${status} must reach the auth gate`);
106
+ assert.equal(r.buf.length && r.buf.includes(PNG), false, `${status} must not leak bytes`);
107
+ }
108
+ });
109
+
110
+ await t('a SHIPPED but security-sensitive task stays builders-only (the one narrowing)', async () => {
111
+ taskRow = { id: 4242, status: 'shipped', security_sensitive: true };
112
+ const r = await get(NAME);
113
+ assert.equal(r.status, 401);
114
+ assert.equal(r.authCalls, 1);
115
+ });
116
+
117
+ await t('an ABSENT task fails closed — no public serve, no crash', async () => {
118
+ taskRow = null;
119
+ const r = await get(NAME);
120
+ assert.equal(r.status, 401);
121
+ assert.equal(r.authCalls, 1);
122
+ });
123
+
124
+ await t('a DB outage fails closed rather than opening the door', async () => {
125
+ taskRow = { id: 4242, status: 'shipped', security_sensitive: false };
126
+ lookupThrows = true;
127
+ const r = await get(NAME);
128
+ lookupThrows = false;
129
+ assert.equal(r.status, 401, 'a lookup failure must not be read as "public"');
130
+ assert.equal(r.authCalls, 1);
131
+ });
132
+
133
+ await t('a malformed name is refused BEFORE any lookup (the traversal wall)', async () => {
134
+ taskRow = { id: 4242, status: 'shipped', security_sensitive: false };
135
+ for (const bad of ['..%2F..%2Fetc%2Fpasswd', 'screenshot.png', 'task-4242-0011.png', 'task-4242-0011223344556677.svg']) {
136
+ const r = await get(bad);
137
+ assert.equal(r.status, 400, bad);
138
+ assert.equal(r.authCalls, 0, `${bad} must not even reach the task lookup`);
139
+ }
140
+ });
141
+
142
+ await t('a well-formed name with no file on disk is a 404, not a 200', async () => {
143
+ taskRow = { id: 4242, status: 'shipped', security_sensitive: false };
144
+ const r = await get('task-4242-ffffffffffffffff.png');
145
+ assert.equal(r.status, 404);
146
+ });
147
+
148
+ server.close();
149
+ await fs.rm(TMP, { recursive: true, force: true });
150
+ console.log(`\n${passed} passed, ${failed} failed`);
151
+ process.exit(failed ? 1 : 0);