@bongos/core 1.24.14 → 1.24.16

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 (165) hide show
  1. package/.bongos-core.json +308 -138
  2. package/.claude/skills/blocker-review/SKILL.md +2 -0
  3. package/.claude/skills/session-board/SKILL.md +29 -0
  4. package/.claude/skills/strand-fix/SKILL.md +1 -1
  5. package/clients/bongos-client/README.md +1 -1
  6. package/clients/bongos-client/bongos-client.global.js +14 -0
  7. package/clients/bongos-client/index.cjs +14 -0
  8. package/clients/bongos-client/index.d.ts +23 -2
  9. package/clients/bongos-client/index.mjs +14 -0
  10. package/docs/adr/0385-money-lives-with-a-processor-credits-stay-a-project-meter.md +4 -0
  11. package/docs/api/openapi.json +369 -6
  12. package/docs/api-reference.md +19 -4
  13. package/docs/architecture.md +6 -0
  14. package/docs/copy-inventory.md +231 -204
  15. package/docs/copy-registry.json +488 -255
  16. package/docs/file-map.md +1 -0
  17. package/docs/module-api-changelog.md +4 -2
  18. package/docs/page-inventory.json +7 -1
  19. package/docs/page-readings.json +1132 -1103
  20. package/migrations/core_289_task_short_name.sql +34 -0
  21. package/modules/ai-studio/db.js +10 -0
  22. package/modules/ai-studio/gemini.js +73 -5
  23. package/modules/ai-studio/openrouter.js +17 -2
  24. package/modules/ai-studio/routes/ai-studio.js +7 -1
  25. package/modules/ai-studio/studio.js +8 -0
  26. package/modules/builder-settings/checkout-freshness.js +71 -0
  27. package/modules/builder-settings/routes/settings.js +26 -0
  28. package/modules/builder-settings/settings.js +2 -0
  29. package/modules/economy/migrations/economy_002_project_account.sql +83 -0
  30. package/modules/economy/module.json +1 -1
  31. package/modules/economy/project-account.js +166 -0
  32. package/modules/economy/routes/project-account.js +40 -0
  33. package/modules/hall-ui/public/ai-studio.css +3 -0
  34. package/modules/hall-ui/public/ai-studio.html +2 -1
  35. package/modules/hall-ui/public/ai-studio.js +72 -1
  36. package/modules/hall-ui/public/atlas.html +1 -1
  37. package/modules/hall-ui/public/blockers.html +1 -1
  38. package/modules/hall-ui/public/board-room.html +1 -1
  39. package/modules/hall-ui/public/city.html +1 -1
  40. package/modules/hall-ui/public/collab.html +1 -1
  41. package/modules/hall-ui/public/commands.html +1 -1
  42. package/modules/hall-ui/public/copy-desk.html +1 -1
  43. package/modules/hall-ui/public/deploy.html +1 -1
  44. package/modules/hall-ui/public/diagrams.html +1 -1
  45. package/modules/hall-ui/public/drachmae.html +1 -1
  46. package/modules/hall-ui/public/fleet.html +1 -1
  47. package/modules/hall-ui/public/gate.html +1 -1
  48. package/modules/hall-ui/public/github-chip.js +85 -0
  49. package/modules/hall-ui/public/goal-map.html +1 -1
  50. package/modules/hall-ui/public/goals.html +1 -1
  51. package/modules/hall-ui/public/government.html +1 -1
  52. package/modules/hall-ui/public/idea.html +1 -1
  53. package/modules/hall-ui/public/ideas.html +1 -1
  54. package/modules/hall-ui/public/index.html +1 -1
  55. package/modules/hall-ui/public/market-watch-item.html +2 -2
  56. package/modules/hall-ui/public/market-watch-item.js +10 -3
  57. package/modules/hall-ui/public/market-watch-item.states.json +41 -0
  58. package/modules/hall-ui/public/market-watch-lib.js +36 -5
  59. package/modules/hall-ui/public/market-watch-rules.html +1 -1
  60. package/modules/hall-ui/public/market-watch-settings.html +1 -1
  61. package/modules/hall-ui/public/market-watch.css +5 -0
  62. package/modules/hall-ui/public/market-watch.html +2 -2
  63. package/modules/hall-ui/public/modules.html +1 -1
  64. package/modules/hall-ui/public/my-agents.html +1 -1
  65. package/modules/hall-ui/public/my-agents.js +3 -0
  66. package/modules/hall-ui/public/panel.css +5 -0
  67. package/modules/hall-ui/public/primer.html +1 -1
  68. package/modules/hall-ui/public/profile.html +1 -1
  69. package/modules/hall-ui/public/project-settings.html +1 -1
  70. package/modules/hall-ui/public/ranks.html +1 -1
  71. package/modules/hall-ui/public/roadmap.html +1 -1
  72. package/modules/hall-ui/public/roster.html +1 -1
  73. package/modules/hall-ui/public/sessions.html +1 -1
  74. package/modules/hall-ui/public/settings-age.js +65 -0
  75. package/modules/hall-ui/public/settings.html +27 -1
  76. package/modules/hall-ui/public/settings.js +1 -0
  77. package/modules/hall-ui/public/shell.js +22 -22
  78. package/modules/hall-ui/public/street.html +1 -1
  79. package/modules/hall-ui/public/studio.html +1 -1
  80. package/modules/hall-ui/public/style.css +28 -0
  81. package/modules/hall-ui/public/task.html +1 -1
  82. package/modules/hall-ui/public/thinking.html +1 -1
  83. package/modules/hall-ui/public/tweak-editor.html +1 -1
  84. package/modules/hall-ui/public/watch.html +1 -1
  85. package/modules/hall-ui/public/work.html +1 -1
  86. package/modules/ideas/blockers.js +13 -7
  87. package/modules/lifecycle/db-claim-reads.js +1 -1
  88. package/modules/lifecycle/db-ship.js +2 -2
  89. package/modules/lifecycle/db-tasks.js +4 -4
  90. package/modules/lifecycle/migrations/lifecycle_019_pay_type_gates.sql +50 -0
  91. package/modules/lifecycle/module.json +1 -0
  92. package/modules/lifecycle/pay-type-gates.js +180 -0
  93. package/modules/lifecycle/publish-reconciler.js +31 -1
  94. package/modules/lifecycle/rebase-gate.js +3 -2
  95. package/modules/lifecycle/routes/claims.js +3 -2
  96. package/modules/lifecycle/routes/pay-type-gates.js +59 -0
  97. package/modules/lifecycle/routes/project-settings.js +4 -0
  98. package/modules/lifecycle/routes/task-write-routes.js +43 -0
  99. package/modules/lifecycle/ship-card.js +4 -1
  100. package/modules/lifecycle/task-name-db.js +55 -0
  101. package/modules/lifecycle/task-name.js +140 -0
  102. package/modules/market-watch/db.js +10 -1
  103. package/modules/market-watch/migrations/market_watch_004_entry_corrections.sql +20 -0
  104. package/modules/market-watch/pages.js +20 -1
  105. package/modules/market-watch/proposals.js +57 -7
  106. package/modules/market-watch/routes/pages.js +2 -0
  107. package/modules/market-watch/routes/rules.js +4 -0
  108. package/modules/platform-identity/terms-acceptance.js +9 -9
  109. package/modules/public-landing/public/account-google.states.json +14 -0
  110. package/modules/public-landing/public/assets/cosmos.css +12 -0
  111. package/modules/public-landing/public/assets/hub-bar.js +44 -1
  112. package/modules/public-landing/public/privacy.html +5 -1
  113. package/modules/public-landing/public/projects-google.states.json +14 -0
  114. package/modules/public-landing/public/projects.html +39 -0
  115. package/modules/public-landing/public/terms.html +12 -2
  116. package/modules/ui-design/kit/fixtures/me-google.json +10 -0
  117. package/modules/ui-design/kit/fixtures/me-sign-in-methods-google.json +4 -0
  118. package/modules/ui-design/kit/serve.js +7 -1
  119. package/package-lock.json +2 -2
  120. package/package.json +1 -1
  121. package/release-notes.json +55 -1
  122. package/scripts/bongos/claim.js +4 -2
  123. package/scripts/bongos/land-watch.js +1 -0
  124. package/scripts/bongos/report-checkout.js +30 -0
  125. package/scripts/bongos/run-unit-tests.js +11 -0
  126. package/scripts/bongos/sessions-board.js +264 -0
  127. package/scripts/bongos/ship-finish.js +1 -0
  128. package/scripts/bongos/ship-flow.js +5 -2
  129. package/scripts/bongos/strand-lease.js +5 -3
  130. package/scripts/bongos/strand-watch.js +38 -7
  131. package/scripts/gds/report-checkout.js +7 -0
  132. package/scripts/gds/sessions-board.js +7 -0
  133. package/src/bongos/agent-standing-rules.js +13 -3
  134. package/src/bongos/routes/me.js +7 -1
  135. package/src/module-api.js +1 -1
  136. package/tests/agent_standing_rules.mjs +18 -1
  137. package/tests/ai_studio_billing.mjs +13 -0
  138. package/tests/ai_studio_gemini_models.mjs +81 -0
  139. package/tests/ai_studio_requests.mjs +24 -0
  140. package/tests/blocker_detail_hydration.mjs +5 -3
  141. package/tests/blocker_tasks_named_db.mjs +74 -0
  142. package/tests/claim_gate_ci_unblock.mjs +1 -1
  143. package/tests/github_chip.mjs +217 -0
  144. package/tests/hall_ai_studio.mjs +129 -5
  145. package/tests/host_topology_skips.mjs +1 -0
  146. package/tests/hub_account_page.mjs +3 -2
  147. package/tests/landing_legal_pages.mjs +3 -2
  148. package/tests/market_watch_data_db.mjs +6 -0
  149. package/tests/market_watch_pages.mjs +87 -2
  150. package/tests/market_watch_rules.mjs +101 -5
  151. package/tests/market_watch_rules_db.mjs +42 -0
  152. package/tests/nav_permission_atoms.mjs +1 -1
  153. package/tests/pay_terms.mjs +8 -0
  154. package/tests/pay_type_gates.mjs +234 -0
  155. package/tests/pay_type_gates_db.mjs +84 -0
  156. package/tests/project_account.mjs +125 -0
  157. package/tests/project_account_db.mjs +97 -0
  158. package/tests/self_healing_start.mjs +162 -0
  159. package/tests/sessions_board.mjs +153 -0
  160. package/tests/settings_age.mjs +110 -0
  161. package/tests/stop_refcheck.mjs +40 -0
  162. package/tests/task_name.mjs +237 -0
  163. package/tests/task_name_db.mjs +79 -0
  164. package/tests/terms_acceptance.mjs +25 -3
  165. package/tests/terms_grandfather_db.mjs +11 -4
@@ -29,6 +29,9 @@ const { validateOrRespond, LIMITS, parseId } = api;
29
29
  const { badKind, badNeeds, detectsMigration } = require('./task-route-helpers.js');
30
30
  const { describeCheckViolation } = require('../pg-constraint-fail.js');
31
31
  const payTermsLib = require('../pay-terms.js'); // task 1004990
32
+ const payTypeGates = require('../pay-type-gates.js'); // task 1004991
33
+ const taskName = require('../task-name.js'); // task 1004958
34
+ const { updateTaskShortName } = require('../task-name-db.js');
32
35
  const { goalNotOpenRefusal } = require('./goal-route-authz.js');
33
36
  const { resolveUpkeepHome, isUpkeepFiling, UPKEEP_HINT } = require('../upkeep-home.js'); // task 1004597
34
37
  // task 1003208: structured logging (pino via the doorway) — was console.*.
@@ -332,6 +335,37 @@ module.exports = function registerTaskWriteRoutes(router) {
332
335
  }
333
336
  });
334
337
 
338
+ // PATCH /tasks/:id/short-name { short_name } — rename a task's 2-3 word name, the
339
+ // one sessions print as "task N - <name>" (task 1004958). Its own route because the
340
+ // owner's rule is that the task's AUTHOR may change it, and PATCH /tasks/:id is gated
341
+ // on task.edit, which an author below Metic does not hold. The name is normalised
342
+ // to task-name.js's alphabet (three lowercase words at most), so what reaches the
343
+ // startup notes is a topic and nothing else; the table CHECK holds the same shape.
344
+ // rank: any-builder, narrowed in the handler — the task's author, or a Metic or Archon.
345
+ router.patch('/tasks/:id/short-name', auth.requireBuilder, async (req, res) => {
346
+ if (validateOrRespond(req, res, { short_name: { required: true, type: 'string', maxLength: 80 } })) return;
347
+ const id = parseId(req, res, { code: 'bad_task_id', positiveInt: true });
348
+ if (id === null) return;
349
+ const name = taskName.normaliseAuthoredName(req.body.short_name);
350
+ if (!name) {
351
+ return res.fail('bad_short_name', { status: 400, message: `short_name must be 1-${taskName.MAX_WORDS} plain words (letters, digits, hyphens), ${taskName.MAX_LEN} characters at most` });
352
+ }
353
+ try {
354
+ const task = await db.getTask(id);
355
+ if (!task) return res.fail('task_not_found', 404);
356
+ const isAuthor = task.created_by != null && String(task.created_by) === String(req.builder.id);
357
+ if (!isAuthor && !auth.rankMeetsThreshold(req.builder.rank, ['metic', 'archon'])) {
358
+ return res.fail('not_task_author', { status: 403, message: 'only the task\'s author (or a Metic or Archon) may rename it' });
359
+ }
360
+ const updated = await updateTaskShortName(id, name);
361
+ if (!updated) return res.fail('task_not_found', 404);
362
+ return res.json({ task: updated });
363
+ } catch (err) {
364
+ log.error('[gds] PATCH /tasks/:id/short-name', err);
365
+ return res.fail('patch_failed', { status: 500, message: 'internal error' });
366
+ }
367
+ });
368
+
335
369
  // PATCH /tasks/:id — supports body.parent_task_id (V2 C2), body.kind (V2 B1),
336
370
  // body.discipline (mig 014), body.parallel_safe
337
371
  // (mig 028), and body.newcomer_friendly (#360 / ADR 0018 + V3.R15 #239 —
@@ -575,6 +609,15 @@ module.exports = function registerTaskWriteRoutes(router) {
575
609
  payTerms = payTermsLib.normalizePayTerms(body.pay_terms);
576
610
  if (payTerms.error) return res.fail('bad_pay_terms', { status: 400, message: payTerms.error });
577
611
  if (!(await db.getTask(id))) return res.fail('task_not_found', 404);
612
+ // task 1004991: a pay type goes on a task only once its gate is cleared (ADR 0385).
613
+ let gateRefusal;
614
+ try {
615
+ gateRefusal = await payTypeGates.refuseUnclearedGates(payTerms.terms);
616
+ } catch (err) {
617
+ log.error('[gds] PATCH /tasks/:id pay_terms gate read', err);
618
+ return res.fail('patch_failed', { status: 500, message: 'internal error' });
619
+ }
620
+ if (gateRefusal) return res.fail(gateRefusal.code, { status: 409, message: gateRefusal.message });
578
621
  }
579
622
  // ---- all gates passed: apply the writes. Order preserved from the old ----
580
623
  // shape — requires_rank still runs AFTER security_sensitive so a
@@ -19,6 +19,7 @@
19
19
  // emitCard() is the one I/O helper the CLIs share (print monospace + write HTML +
20
20
  // print the [otb-card-html] marker the skills pick up).
21
21
 
22
+ const { safeTaskName, taskRef } = require('./task-name.js'); // task 1004958
22
23
  const fs = require('node:fs');
23
24
  const os = require('node:os');
24
25
  const path = require('node:path');
@@ -523,6 +524,8 @@ function buildCardModel(input = {}) {
523
524
 
524
525
  return {
525
526
  stage, cfg, brand, taskId,
527
+ // task 1004958: the task's 2-3 word name, so the confirm line reads "task N - name".
528
+ shortName: safeTaskName(input.shortName),
526
529
  title: input.title || '',
527
530
  value,
528
531
  notes: input.notes || '',
@@ -751,7 +754,7 @@ function sanitizeOneLine(s) {
751
754
 
752
755
  function confirmLine(model) {
753
756
  const m = model || {};
754
- const id = m.taskId != null ? `task ${m.taskId}` : 'task';
757
+ const id = m.taskId != null ? taskRef(m.taskId, m.shortName) : 'task';
755
758
  const word = (m.cfg && m.cfg.statusWord) ? String(m.cfg.statusWord).toLowerCase() : String(m.stage || 'done');
756
759
  // The stage's own rows already hold exactly the facts that stage cares about
757
760
  // (buildCardModel picks them per stage); reuse them rather than re-deriving,
@@ -0,0 +1,55 @@
1
+ 'use strict';
2
+ // modules/lifecycle/task-name-db.js — the two writes behind a task's short name
3
+ // (task 1004958): the author's rename and the backfill of unnamed rows.
4
+ //
5
+ // WHY ITS OWN FILE. createTask (db-tasks.js) writes the name on every new task;
6
+ // these two are the rest of the feature, and db-tasks.js sits at the 1,500-line
7
+ // ratchet. The name itself is derived ONLY in task-name.js, so a backfilled name
8
+ // and a born-with name can never disagree.
9
+
10
+ const api = require('../../src/module-api');
11
+ const { pool } = api;
12
+ const { deriveTaskName } = require('./task-name.js');
13
+
14
+ // The author's rename. The caller passes a name already through
15
+ // task-name.normaliseAuthoredName; the table CHECK holds the same shape.
16
+ async function updateTaskShortName(id, shortName, exec = pool) {
17
+ const { rows } = await exec.query(
18
+ 'UPDATE tasks SET short_name = $1, updated_at = now() WHERE id = $2 RETURNING *',
19
+ [shortName, id]
20
+ );
21
+ return rows[0] ?? null;
22
+ }
23
+
24
+ // Name the tasks created before names existed (or by a path that skipped
25
+ // createTask). Reads a batch of unnamed rows, derives each name in JS and writes
26
+ // them in one statement; `short_name IS NULL` on the write too, so an author's
27
+ // edit landing in between is never overwritten. A title with no topic word keeps
28
+ // NULL and simply prints "task N". Stateless: the caller walks forward by passing
29
+ // back `next` as `after`, so a row that stays unnamed is passed over rather than
30
+ // re-read every batch, and two callers can never disturb each other's walk. The
31
+ // publish-reconciler walks from 0 once an hour.
32
+ async function backfillTaskNames({ after = 0, limit = 500, exec = pool } = {}) {
33
+ const { rows } = await exec.query(
34
+ 'SELECT id, title, description FROM tasks WHERE short_name IS NULL AND id > $1 ORDER BY id LIMIT $2',
35
+ [after, limit]
36
+ );
37
+ if (!rows.length) return { named: 0, scanned: 0, next: after };
38
+ const next = Number(rows[rows.length - 1].id);
39
+ const ids = [];
40
+ const names = [];
41
+ for (const r of rows) {
42
+ const name = deriveTaskName(r.title, r.description);
43
+ if (name) { ids.push(r.id); names.push(name); }
44
+ }
45
+ if (!ids.length) return { named: 0, scanned: rows.length, next };
46
+ const res = await exec.query(
47
+ `UPDATE tasks t SET short_name = v.name
48
+ FROM unnest($1::bigint[], $2::text[]) AS v(id, name)
49
+ WHERE t.id = v.id AND t.short_name IS NULL`,
50
+ [ids, names]
51
+ );
52
+ return { named: res.rowCount || 0, scanned: rows.length, next };
53
+ }
54
+
55
+ module.exports = { updateTaskShortName, backfillTaskNames };
@@ -0,0 +1,140 @@
1
+ 'use strict';
2
+ // modules/lifecycle/task-name.js — every task's short name: 2-3 plain words, so a
3
+ // person reads "task 1004745 - payout approvals" instead of a bare number (task 1004958).
4
+ //
5
+ // WHY. Sessions mention task numbers in passing ("task 1004745 is stranded", "after
6
+ // 1004927 lands") as if the builder could keep up with them. A number alone means
7
+ // nothing to the reader. The name is STORED (tasks.short_name, lifecycle_019), so it
8
+ // is the same on every surface; createTask fills it, the publish-reconciler backfills
9
+ // the rows created before it, and the task's author may change it (PATCH /tasks/:id).
10
+ //
11
+ // NOT A TRUNCATED TITLE. Titles open with words that describe nothing — "Goal 1000092
12
+ // N9:", "fix(auth):", "PMS-V2 —", "V4.R38 —" — so `deriveTaskName` strips those labels,
13
+ // keeps the title's head clause, drops filler words and keeps the first three that say
14
+ // what the work is about.
15
+ //
16
+ // A SAFE STRING BY CONSTRUCTION. The startup notes inject text into every session and
17
+ // print no builder-authored text raw (scripts/bongos/strand-watch.js). A name is at most
18
+ // three lowercase words of [a-z0-9-] and 40 characters; `safeTaskName` re-checks that on
19
+ // every read and the table CHECK holds it on every write, so a name can carry a topic
20
+ // and nothing else. An author's edit goes through the same normaliser.
21
+ //
22
+ // Requires nothing, so a CLI can print a name without loading the pool.
23
+
24
+ const MAX_WORDS = 3;
25
+ const MAX_LEN = 40;
26
+ const MAX_WORD_LEN = 20;
27
+ // The same pattern, character for character, as core_289's CHECK (tests/task_name.mjs).
28
+ const NAME_RE = /^[a-z0-9]+(-[a-z0-9]+)*( [a-z0-9]+(-[a-z0-9]+)*){0,2}$/;
29
+
30
+ // Words that carry no topic: articles, prepositions, auxiliaries, quantifiers.
31
+ const STOP = new Set((
32
+ 'a an the and or but nor of to in on at by for from with without into onto over under about as '
33
+ + 'is are was were be been being am do does did done has have had having can could will would '
34
+ + 'shall should may might must need needs needed get gets got let lets make makes it its this '
35
+ + 'that these those there their them they we our us you your i me my he she his her so if then '
36
+ + 'than when what which who whom whose why how where while not no yes all any each every some '
37
+ + 'more most less only just also too very via per vs etc one two n new now still again once '
38
+ // …and labels that say how a task was filed, not what it is about.
39
+ + 'phase part step urgent hotfix wip tracking bundle proof spike'
40
+ ).split(' '));
41
+
42
+ // Labels a title opens with that describe nothing. Applied repeatedly from the start.
43
+ const LABELS = [
44
+ /^goal\s+\d+\s*(?:[a-z]{1,4}\d+[a-z]?\s*)?[:—–-]?\s*/i, // "Goal 1000092 N9:"
45
+ /^(?:fix|feat|chore|docs|diag|refactor|test|tests|perf|ci|build|style|revert)(?:\([^)]*\))?!?\s*:\s*/i, // "fix(auth):"
46
+ // Every repeat is bounded (no nested unbounded quantifier), so a hostile title
47
+ // cannot make this backtrack super-linearly.
48
+ /^[A-Z][A-Za-z]{0,5}\d{0,4}(?:[.-][A-Za-z]{0,6}\d{1,4}[a-z]?){1,3}\s{0,3}[:—–-]\s*/, // "V4.R38 —", "PMS-V2 —", "BV2.TW14:"
49
+ /^[A-Z]{2,6}\s*[:—–]\s*/, // "SEC:", "PMS —"
50
+ /^(?:task|idea|blocker)\s+#?\d+\s*[:—–-]?\s*/i, // "task 123:"
51
+ /^\[[^\]]{0,40}\]\s*/, // "[wip]"
52
+ /^#?\d+\s*[:—–-]\s*/, // "123 —"
53
+ ];
54
+
55
+ // The head clause ends at the first strong separator.
56
+ const HEAD_CUT = /\s[—–]\s|:\s|;\s|\s\(|\s\+\s|\s-\s|\s→\s|\.\s/;
57
+
58
+ // Only the opening of a title can hold a name; reading at most this much bounds the
59
+ // work per title whatever its length.
60
+ const MAX_TITLE_READ = 200;
61
+
62
+ function stripLabels(title) {
63
+ let s = String(title || '').slice(0, MAX_TITLE_READ).trim();
64
+ for (let guard = 0; guard < 6; guard++) {
65
+ const before = s;
66
+ for (const re of LABELS) s = s.replace(re, '');
67
+ s = s.trim();
68
+ if (s === before) break;
69
+ }
70
+ return s;
71
+ }
72
+
73
+ // One raw token to a name word, or null. "ship.js" → "ship", "gds_session" →
74
+ // "gds-session", "#898" / "2026-06-12" / "V2" → null.
75
+ function toWord(raw) {
76
+ let w = String(raw).normalize('NFKD').replace(/[\u0300-\u036f]/g, '').toLowerCase();
77
+ w = w.replace(/\.(?:js|mjs|cjs|ts|md|json|sql|sh|yml|yaml|html|css)$/, '');
78
+ w = w.replace(/_/g, '-').replace(/[^a-z0-9-]/g, '').replace(/-+/g, '-').replace(/^-|-$/g, '');
79
+ if (!w || STOP.has(w)) return null;
80
+ if (/\d/.test(w)) return null; // ids, dates, version labels, counts
81
+ if (w.length > MAX_WORD_LEN) w = w.slice(0, MAX_WORD_LEN).replace(/-+$/, '');
82
+ return w.length >= 2 ? w : null;
83
+ }
84
+
85
+ function wordsOf(text) {
86
+ return String(text).normalize('NFKD').replace(/[\u0300-\u036f]/g, '').split(/[\s/,]+/).map((t) => t.replace(/^[^\w]+|[^\w]+$/g, '')).map(toWord).filter(Boolean);
87
+ }
88
+
89
+ function fit(words) {
90
+ const out = [];
91
+ for (const w of words) {
92
+ if (out.includes(w)) continue;
93
+ const next = [...out, w].join(' ');
94
+ if (next.length > MAX_LEN) break;
95
+ out.push(w);
96
+ if (out.length === MAX_WORDS) break;
97
+ }
98
+ return out.length ? out.join(' ') : null;
99
+ }
100
+
101
+ function nameFrom(text) {
102
+ const body = stripLabels(text);
103
+ const cut = body.search(HEAD_CUT);
104
+ const head = cut > 0 ? body.slice(0, cut) : body;
105
+ const headWords = wordsOf(head);
106
+ // A head clause of one word ("Proof: forced-drift …") is itself a label; read on.
107
+ return fit(headWords.length >= 2 ? headWords : wordsOf(body));
108
+ }
109
+
110
+ // The name for a task: from its title, else (a title of only ids or labels) from the
111
+ // opening of its description. Null only when neither has a topic word at all.
112
+ function deriveTaskName(title, description) {
113
+ return nameFrom(title) || (description ? nameFrom(String(description).split(/\n\s*\n/)[0]) : null);
114
+ }
115
+
116
+ // A stored or authored name, re-checked. Returns the name or null — never anything else.
117
+ function safeTaskName(v) {
118
+ if (typeof v !== 'string') return null;
119
+ return v.length <= MAX_LEN && NAME_RE.test(v) ? v : null;
120
+ }
121
+
122
+ // An author's edit, normalised to the same alphabet. Null when nothing usable is left.
123
+ function normaliseAuthoredName(input) {
124
+ if (typeof input !== 'string') return null;
125
+ const words = input.split(/\s+/).map((t) => {
126
+ const w = t.normalize('NFKD').replace(/[\u0300-\u036f]/g, '').toLowerCase()
127
+ .replace(/_/g, '-').replace(/[^a-z0-9-]/g, '').replace(/-+/g, '-').replace(/^-|-$/g, '');
128
+ return w.slice(0, MAX_WORD_LEN).replace(/-+$/, '');
129
+ }).filter(Boolean);
130
+ if (!words.length || words.length > MAX_WORDS) return null;
131
+ return safeTaskName(words.join(' '));
132
+ }
133
+
134
+ // How a task is written to a person: "task N - name", or "task N" when it has none yet.
135
+ function taskRef(id, name) {
136
+ const n = safeTaskName(name);
137
+ return n ? `task ${id} - ${n}` : `task ${id}`;
138
+ }
139
+
140
+ module.exports = { deriveTaskName, safeTaskName, normaliseAuthoredName, taskRef, MAX_WORDS, MAX_LEN, NAME_RE };
@@ -163,9 +163,18 @@ async function getItem(pool, { project, id, knownRuns = null }) {
163
163
 
164
164
  const CASE_FILE_SQL = `
165
165
  SELECT e.id, e.run_id, e.type, e.found_on, e.takeaway, e.dimension, e.source_url,
166
- e.source_label, e.confidence, e.pinned, e.created_at
166
+ e.source_label, e.confidence, e.pinned, e.created_at,
167
+ c.decided_at AS corrected_at, c.reason AS correction_reason, COALESCE(c.wrong, false) AS marked_wrong
167
168
  FROM market_watch_log_entries e
168
169
  JOIN market_watch_items i ON i.id = e.item_id
170
+ -- the entry's latest applied correction (task 1005016): the proposal row is where who
171
+ -- proposed it, who approved it, when, why and the text it replaced are kept
172
+ LEFT JOIN LATERAL (
173
+ SELECT p.decided_at, p.reason, p.payload->>'wrong' = 'true' AS wrong
174
+ FROM market_watch_proposals p
175
+ WHERE p.project = i.project AND p.log_entry_id = e.id AND p.kind = 'correct_entry' AND p.status = 'applied'
176
+ ORDER BY p.decided_at DESC, p.id DESC
177
+ LIMIT 1) c ON true
169
178
  WHERE i.project = $1 AND e.item_id = $2
170
179
  ORDER BY e.pinned DESC, e.found_on DESC, e.id DESC`;
171
180
 
@@ -0,0 +1,20 @@
1
+ -- market_watch_004_entry_corrections.sql - the lookup of an entry's applied correction
2
+ -- (task 1005016, goal 1000137).
3
+ --
4
+ -- An approved correct_entry proposal is applied to its log entry, and the proposal row
5
+ -- keeps who, when, why and the replaced text. Two reads look that correction up once per
6
+ -- entry: the item page's case file (modules/market-watch/db.js CASE_FILE_SQL, a LATERAL
7
+ -- join for the latest applied correction) and the home's weekly reports
8
+ -- (modules/market-watch/pages.js WEEK_ENTRIES_SQL, which skips an entry marked wrong).
9
+ -- Without this index each of those scans the project's proposals per entry. The partial
10
+ -- index holds exactly the rows they can match, in the order they read them.
11
+ --
12
+ -- Additive and idempotent, like 001 to 003.
13
+
14
+ BEGIN;
15
+
16
+ CREATE INDEX IF NOT EXISTS market_watch_proposals_entry_corrections
17
+ ON market_watch_proposals (log_entry_id, decided_at DESC, id DESC)
18
+ WHERE kind = 'correct_entry' AND status = 'applied';
19
+
20
+ COMMIT;
@@ -30,6 +30,7 @@ const STREAK_SLOTS = 9;
30
30
  const REASON_MAX = 280;
31
31
  const NAME_MAX = 120;
32
32
  const URL_MAX = 500;
33
+ const TAKEAWAY_MAX = 500;
33
34
  const DIM_RE = /^[a-z][a-z0-9_]{0,59}$/;
34
35
  // The four the item page's "Propose a change" offers, then the two the other buttons file.
35
36
  const ITEM_KINDS = Object.freeze(['rescore', 'move_kind', 'add_source', 'correct_entry', 'remove_item']);
@@ -59,6 +60,11 @@ const WEEK_ENTRIES_SQL = `
59
60
  FROM market_watch_log_entries e
60
61
  JOIN market_watch_items i ON i.id = e.item_id
61
62
  WHERE i.project = $1 AND i.status <> 'removed' AND e.found_on >= $2::date
63
+ -- an entry whose latest applied correction marked it wrong (task 1005016) is no report
64
+ AND NOT COALESCE((SELECT p.payload->>'wrong' = 'true' FROM market_watch_proposals p
65
+ WHERE p.project = i.project AND p.log_entry_id = e.id AND p.kind = 'correct_entry'
66
+ AND p.status = 'applied'
67
+ ORDER BY p.decided_at DESC, p.id DESC LIMIT 1), false)
62
68
  ORDER BY e.found_on DESC, e.id DESC
63
69
  LIMIT 1000`;
64
70
 
@@ -208,6 +214,9 @@ function shapeItem({ item, entries, history, rules, openProposals = 0 }) {
208
214
  id: Number(e.id), run: e.run, type: e.type, found_on: isoDay(e.found_on), takeaway: e.takeaway,
209
215
  dimension: e.dimension, source_url: e.source_url, source_label: e.source_label,
210
216
  confidence: Number(e.confidence), pinned: !!e.pinned,
217
+ // An applied correction (task 1005016): when it was made and why. The takeaway
218
+ // above already reads as corrected; a wrong entry keeps its text, flagged.
219
+ corrected: e.corrected_at ? { on: isoDay(e.corrected_at), reason: e.correction_reason || '', wrong: !!e.marked_wrong } : null,
211
220
  })),
212
221
  open_proposals: Number(openProposals) || 0,
213
222
  };
@@ -287,9 +296,19 @@ function normalizeProposal(body, { item = null, rubricKeys = null } = {}) {
287
296
  return { ok: true, value: { ...base, payload: { source_url: url } } };
288
297
  }
289
298
  if (kind === 'correct_entry') {
299
+ // The proposal carries the correction itself (task 1005016): either the takeaway as
300
+ // it should read, or a flag that the entry is wrong. The reason says why. Approving
301
+ // it on the rules page applies it to the entry (proposals.js).
290
302
  const entry = String(b.log_entry_id || '');
291
303
  if (!/^[1-9]\d{0,17}$/.test(entry)) return { ok: false, error: 'invalid_entry', message: 'Pick the entry to correct.' };
292
- return { ok: true, value: { ...base, log_entry_id: Number(entry), payload: {} } };
304
+ const wrong = b.wrong === true || b.wrong === 'true';
305
+ const takeaway = oneLine(b.takeaway);
306
+ if (wrong && takeaway) return { ok: false, error: 'invalid_correction', message: 'Either correct the entry or mark it as wrong, not both.' };
307
+ if (wrong) return { ok: true, value: { ...base, log_entry_id: Number(entry), payload: { wrong: true, summary: 'Mark an entry as wrong' } } };
308
+ if (!takeaway) return { ok: false, error: 'correction_required', message: 'Write the entry as it should read, or mark it as wrong.' };
309
+ if (takeaway.length > TAKEAWAY_MAX) return { ok: false, error: 'correction_too_long', message: `Keep the correction to ${TAKEAWAY_MAX} characters.` };
310
+ const short = takeaway.length > 90 ? `${takeaway.slice(0, 89)}…` : takeaway;
311
+ return { ok: true, value: { ...base, log_entry_id: Number(entry), payload: { takeaway, summary: `Correct an entry to read "${short}"` } } };
293
312
  }
294
313
  return { ok: true, value: { ...base, payload: {} } }; // remove_item: stop tracking
295
314
  }
@@ -23,8 +23,12 @@
23
23
  // name and link are already tracked as the 'to' kind.
24
24
  // add_source payload { source_url }: the link joins the item's facts.watch_sources
25
25
  // (shown on the item page; the weekly run does not read it yet).
26
- // correct_entry payload {} with log_entry_id: the reason IS the correction, written for
27
- // a person to act on, so the decision is recorded as 'approved' only.
26
+ // correct_entry payload { takeaway } or { wrong: true } with log_entry_id (task 1005016):
27
+ // the entry's takeaway is rewritten to the corrected one, or the entry is
28
+ // left as found and read as marked wrong (db.caseFile; the home's reports
29
+ // skip it). The text it replaced is kept on the proposal (payload.was),
30
+ // beside who proposed it, who approved it, when, and the reason why. A
31
+ // correct_entry filed before then carries no correction and is refused.
28
32
  // The four item-page payloads are written by modules/market-watch/pages.js (task 1004939).
29
33
  // A carried-out approval is status 'applied'; a recorded one is 'approved'. A decline is
30
34
  // 'rejected'. Every decision names who made it and when (decided_by, decided_at).
@@ -58,7 +62,7 @@ const SQL = Object.freeze({
58
62
  VALUES ($1, $2, 'member', $3, $4, $5::jsonb, $6, $7)
59
63
  RETURNING id, kind, status, created_at`,
60
64
  lock: `-- mw:proposal-lock
61
- SELECT id, kind, origin, item_id, rule_version_id, payload, reason, proposed_by, status
65
+ SELECT id, kind, origin, item_id, log_entry_id, rule_version_id, payload, reason, proposed_by, status
62
66
  FROM market_watch_proposals WHERE id = $1 AND project = $2 FOR UPDATE`,
63
67
  maxVersion: `-- mw:rules-max-version
64
68
  SELECT COALESCE(max(version), 0)::int AS v FROM market_watch_rule_versions WHERE project = $1`,
@@ -117,6 +121,22 @@ const SQL = Object.freeze({
117
121
  updated_at = now()
118
122
  WHERE id = $1 AND project = $2 AND status <> 'removed'
119
123
  RETURNING id`,
124
+ // The entry a correction names, locked, scoped to the proposal's item and this project.
125
+ entryLock: `-- mw:entry-lock
126
+ SELECT e.id, e.item_id, e.run_id, e.takeaway
127
+ FROM market_watch_log_entries e
128
+ JOIN market_watch_items i ON i.id = e.item_id
129
+ WHERE e.id = $1 AND e.item_id = $3 AND i.project = $2
130
+ FOR UPDATE OF e`,
131
+ // The corrected takeaway, unless the same item already holds that takeaway from the
132
+ // same run (the unique index market_watch_log_entries_once would refuse it).
133
+ entryCorrect: `-- mw:entry-correct
134
+ UPDATE market_watch_log_entries e SET takeaway = $2::text
135
+ WHERE e.id = $1
136
+ AND NOT EXISTS (SELECT 1 FROM market_watch_log_entries o
137
+ WHERE o.item_id = e.item_id AND o.run_id = e.run_id AND o.id <> e.id
138
+ AND md5(o.takeaway) = md5($2::text))
139
+ RETURNING e.id`,
120
140
  atVote: `-- mw:proposal-at-vote
121
141
  UPDATE market_watch_proposals SET payload = payload || jsonb_build_object('matter_id', $3::text)
122
142
  WHERE id = $1 AND project = $2 AND status = 'open'`,
@@ -206,9 +226,20 @@ function itemChange(kind, payload) {
206
226
  return null;
207
227
  }
208
228
 
229
+ // entryCorrection(payload) → { takeaway } | { wrong: true, takeaway: null } | null — what a
230
+ // correct_entry carries (pages.normalizeProposal writes it), re-checked here. A proposal
231
+ // filed before task 1005016 carries neither, so it cannot be applied (no_correction).
232
+ function entryCorrection(payload) {
233
+ const p = payload || {};
234
+ if (p.wrong === true) return { wrong: true, takeaway: null };
235
+ const t = String(p.takeaway == null ? '' : p.takeaway).replace(/[\u0000-\u001f\u007f]+/g, ' ').replace(/\s+/g, ' ').trim();
236
+ return t && t.length <= 500 ? { takeaway: t } : null;
237
+ }
238
+
209
239
  // decideProposal(pool, { project, id, decision, decidedBy, via, withTx }) →
210
240
  // { ok: true, status, applied, version?, item_id? } |
211
- // { ok: false, reason: 'not_found' | 'not_open' | 'no_active_rules' | 'no_longer_applies' | 'bad_payload' | 'item_gone' }
241
+ // { ok: false, reason: 'not_found' | 'not_open' | 'no_active_rules' | 'no_longer_applies' | 'bad_payload' | 'item_gone'
242
+ // | 'no_correction' | 'entry_gone' | 'same_as_another_entry' }
212
243
  // `via` is recorded on the proposal's payload when a government vote decided it
213
244
  // ({ decided_via: 'vote', matter_id }); an owner's own decision records nothing extra.
214
245
  // `withTx(fn)` runs fn(client) in one transaction (the doorway's withTx in production).
@@ -219,8 +250,8 @@ async function decideProposal({ project, id, decision, decidedBy, via = null, wi
219
250
  const { rows: [p] } = await client.query(SQL.lock, [id, project]);
220
251
  if (!p) return { ok: false, reason: 'not_found' };
221
252
  if (p.status !== 'open') return { ok: false, reason: 'not_open', status: p.status };
222
- const extra = JSON.stringify(via || {});
223
- const decide = async (status, { versionId = null, itemId = null } = {}) => {
253
+ const decide = async (status, { versionId = null, itemId = null, kept = null } = {}) => {
254
+ const extra = JSON.stringify({ ...(kept || {}), ...(via || {}) });
224
255
  const { rows: [d] } = await client.query(SQL.decide, [id, project, status, decidedBy, versionId, itemId, extra]);
225
256
  return d;
226
257
  };
@@ -268,9 +299,28 @@ async function decideProposal({ project, id, decision, decidedBy, via = null, wi
268
299
  await decide('applied');
269
300
  return { ok: true, status: 'applied', applied: true, item_id: Number(p.item_id) };
270
301
  }
302
+ if (p.kind === 'correct_entry') {
303
+ const c = entryCorrection(p.payload);
304
+ if (!c || p.log_entry_id == null || p.item_id == null) return { ok: false, reason: 'no_correction' };
305
+ const { rows: [e] } = await client.query(SQL.entryLock, [p.log_entry_id, project, p.item_id]);
306
+ if (!e) return { ok: false, reason: 'entry_gone' };
307
+ // A correction that reads the same as the entry already does (compared after the
308
+ // same one-line normalising entryCorrection applies) writes nothing to the entry;
309
+ // the proposal is still 'applied', since the entry now reads as it asked.
310
+ const now = String(e.takeaway).replace(/[\u0000-\u001f\u007f]+/g, ' ').replace(/\s+/g, ' ').trim();
311
+ if (c.takeaway != null && c.takeaway !== now) {
312
+ const { rows: [done] } = await client.query(SQL.entryCorrect, [e.id, c.takeaway]);
313
+ if (!done) return { ok: false, reason: 'same_as_another_entry' };
314
+ }
315
+ // Who proposed it (proposed_by), who approved it and when (decided_by, decided_at)
316
+ // and why (reason) are the proposal row's own columns; the text it replaced is kept
317
+ // on the row too, so the original finding is never lost.
318
+ await decide('applied', { kept: { was: e.takeaway } });
319
+ return { ok: true, status: 'applied', applied: true, item_id: Number(p.item_id), log_entry_id: Number(e.id) };
320
+ }
271
321
  await decide('approved');
272
322
  return { ok: true, status: 'approved', applied: false };
273
323
  });
274
324
  }
275
325
 
276
- module.exports = { createProposal, decideProposal, getOpen, markAtVote, itemFromPayload, itemChange, MAX_OPEN_PER_MEMBER };
326
+ module.exports = { createProposal, decideProposal, getOpen, markAtVote, itemFromPayload, itemChange, entryCorrection, MAX_OPEN_PER_MEMBER };
@@ -87,6 +87,8 @@ module.exports = function buildMarketWatchPagesRouter(deps = {}) {
87
87
  value: {},
88
88
  url: { type: 'string', maxLength: 2000 },
89
89
  log_entry_id: {},
90
+ takeaway: { type: 'string', maxLength: 2000 }, // correct_entry: the entry as it should read
91
+ wrong: {}, // correct_entry: or the entry is wrong
90
92
  scout: { type: 'string', enum: pages.KINDS },
91
93
  name: { type: 'string', maxLength: 2000 },
92
94
  })) return;
@@ -87,6 +87,10 @@ const DECIDE_REFUSALS = Object.freeze({
87
87
  no_longer_applies: [409, 'The rules changed since this was proposed, so it no longer fits. Decline it and propose again.'],
88
88
  bad_payload: [409, 'This proposal does not name anything to add.'],
89
89
  item_gone: [409, 'That item is no longer on this project.'],
90
+ // a correct_entry (task 1005016)
91
+ no_correction: [409, 'This proposal does not say how the entry should read. Decline it and propose the correction again.'],
92
+ entry_gone: [409, 'That entry is no longer in this item\'s case file.'],
93
+ same_as_another_entry: [409, 'The item already has an entry from that run that reads the same. Decline this one.'],
90
94
  });
91
95
 
92
96
  module.exports = function buildMarketWatchRulesRouter() {
@@ -48,11 +48,10 @@
48
48
  // ever READS the marker. tests/terms_acceptance.mjs holds both rules, and fails
49
49
  // any other server file that writes these columns or names the marker.
50
50
  //
51
- // A LATER REVISION IS A KNOWN GAP, NOT A SILENT ONE. Because the version is
52
- // recorded and a sign-in never overwrites it, publishing a new TERMS_VERSION leaves every
53
- // existing row naming the text it actually agreed to — so the platform can see
54
- // who has not accepted the new one. Re-acceptance on revision is deliberately
55
- // NOT built here (ADR 0213 D4): it is a product flow, not a storage shape.
51
+ // A REVISION IS RE-ACCEPTED AT THE NEXT BROWSER SIGN-IN (2026-10-30, the AI
52
+ // features revision; supersedes ADR 0213 D4). Until then a row keeps naming the
53
+ // text it actually agreed to, so the platform can see who has not accepted the
54
+ // new one. A sign-in under the SAME version still never restamps.
56
55
  'use strict';
57
56
 
58
57
  // The version of the platform terms currently served at /terms. It is stamped
@@ -65,7 +64,7 @@
65
64
  // The terms_version column it fills is WRITE-ONLY today, on purpose: nothing
66
65
  // reads it until a revision needs to know who accepted which text (ADR 0213 D5).
67
66
  // It is not dead — dropping it would erase exactly that answer.
68
- const TERMS_VERSION = '2026-08-29';
67
+ const TERMS_VERSION = '2026-10-30';
69
68
 
70
69
  // A Postgres placeholder, e.g. '$5'. Interpolated into SQL, so it is validated
71
70
  // rather than trusted — the account-visibility.js alias-guard pattern.
@@ -96,8 +95,9 @@ function acceptanceInsertValues(activateParam, versionParam) {
96
95
 
97
96
  // The ON CONFLICT DO UPDATE SET fragment — the write-once half. On the
98
97
  // activating sign-in: a grandfathered acceptance is replaced outright; any other
99
- // acceptance on record is kept (COALESCE), so a second sign-in re-reads the terms
100
- // without restamping them; and the source becomes 'signin' either way, since a
98
+ // acceptance of an older version is replaced too (the person signed in under
99
+ // the new text); any other acceptance on record is kept (COALESCE), so a
100
+ // second sign-in re-reads the terms without restamping them; and the source becomes 'signin' either way, since a
101
101
  // human just accepted. Keying the replacement on the grandfather marker, not on
102
102
  // "no signin recorded", matters: an acceptance with no source (written by the
103
103
  // pre-024 code in the deploy window) is a real sign-in and keeps its date. A
@@ -111,7 +111,7 @@ function acceptanceConflictSet(activateParam, versionParam) {
111
111
  assertPlaceholder('acceptanceConflictSet', versionParam);
112
112
  const self = 'platform_identity_accounts';
113
113
  const signin = `${activateParam}::boolean`;
114
- const replaces = `WHEN ${signin} AND ${self}.terms_accepted_source = 'grandfathered'`;
114
+ const replaces = `WHEN ${signin} AND (${self}.terms_accepted_source = 'grandfathered' OR ${self}.terms_version IS DISTINCT FROM ${versionParam}::text)`;
115
115
  return `terms_accepted_at = CASE ${replaces} THEN now()`
116
116
  + ` WHEN ${signin} THEN COALESCE(${self}.terms_accepted_at, now()) ELSE ${self}.terms_accepted_at END,`
117
117
  + ` terms_version = CASE ${replaces} THEN ${versionParam}::text`
@@ -0,0 +1,14 @@
1
+ {
2
+ "_": "The Link GitHub chip (task 1004996): a Google-only person, no GitHub way in, so the hub bar draws one very small chip with the engineer's icon at the head of its right side, linking to the GitHub link start; the ways-in section lists Google only with Link GitHub beside GitHub. stub.fixtures points the identity at fixtures/me-google.json (the stand-in login ~builder) and the ways-in read at fixtures/me-sign-in-methods-google.json. The default account.states.json (GitHub linked) draws no chip.",
3
+ "page": "/account",
4
+ "surface": "public-landing",
5
+ "stub": { "fixtures": { "me": "me-google", "me__sign-in-methods": "me-sign-in-methods-google" } },
6
+ "modes": ["dark"],
7
+ "modeQuery": false,
8
+ "ignoreRequests": ["/api/(gds|bongos)/me$", "/api/(gds|bongos)/me/cross-project$"],
9
+ "ignoreConsole": ["/api/(gds|bongos)/me", "\\b401\\b", "Unauthorized"],
10
+ "ignoreControls": [".vh"],
11
+ "states": {
12
+ "in-google": { "auth": true, "url": "/account#aWaysIn", "actions": [["wait", 500]], "expect": { "visible": ["#ghChip", "#who", "#waysList"], "attr": [["#ghChip", "href", "/api/bongos/auth/link/github/start"], ["#ghChip", "aria-label", "Link GitHub — needed for coding work"]] } }
13
+ }
14
+ }
@@ -647,6 +647,16 @@ p.sec-p{font-size:var(--fs-lede);font-weight:500;color:var(--ink-soft);line-heig
647
647
  border-radius:var(--radius-pill);background:var(--accent);color:var(--accent-on);
648
648
  font-family:var(--font-body);font-size:12px;font-weight:700;line-height:1;letter-spacing:0;vertical-align:middle;}
649
649
  .who>.invBadge{max-width:none;overflow:visible;}
650
+ /* the Link GitHub chip (task 1004996; hub-bar.js, projects.html): very small and quiet, for
651
+ someone with no GitHub linked — a hairline pill with the engineer's glyph, never a banner;
652
+ under 900 the words fold away and the glyph keeps a 24px target */
653
+ .ghChip{display:inline-flex;align-items:center;justify-content:center;gap:5px;min-height:24px;min-width:24px;padding:0 9px 0 7px;
654
+ flex:none;border:1px solid var(--rule);border-radius:var(--radius-pill);color:var(--ink-soft);
655
+ font-family:var(--label-face);font-size:11.5px;line-height:1;white-space:nowrap;text-decoration:none;
656
+ transition:color .2s cubic-bezier(.2,.7,.3,1),border-color .2s cubic-bezier(.2,.7,.3,1);}
657
+ .ghChip:hover{color:var(--ink);border-color:var(--ink-faint);}
658
+ .ghChip:focus-visible{outline:2px solid var(--focus);outline-offset:2px;}
659
+ .ghChip svg{width:13px;height:13px;flex:none;fill:none;stroke:currentColor;stroke-width:1.4;stroke-linecap:round;stroke-linejoin:round;}
650
660
  .bandT .invBadge{margin-left:10px;}
651
661
  .invWaiting{margin:6px 0 4px;font-size:15.5px;font-weight:600;}
652
662
  /* keyboard focus on the two search pills is VISIBLE — the input's own outline
@@ -728,6 +738,8 @@ p.sec-p{font-size:var(--fs-lede);font-weight:500;color:var(--ink-soft);line-heig
728
738
  .qWrap{flex:1 1 90px;max-width:480px;gap:10px;}
729
739
  .trig{font-size:13.5px;}
730
740
  .who>span{display:none;}
741
+ .ghChip{padding:0;}
742
+ .ghChip__label{display:none;}
731
743
  .signinDoor{padding:0 16px;font-size:13.5px;}
732
744
  .bandIn{padding:0 12px 16px;}
733
745
  .bandRow{grid-template-columns:1fr;gap:0;}