@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.
- package/.bongos-core.json +308 -138
- package/.claude/skills/blocker-review/SKILL.md +2 -0
- package/.claude/skills/session-board/SKILL.md +29 -0
- package/.claude/skills/strand-fix/SKILL.md +1 -1
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +14 -0
- package/clients/bongos-client/index.cjs +14 -0
- package/clients/bongos-client/index.d.ts +23 -2
- package/clients/bongos-client/index.mjs +14 -0
- package/docs/adr/0385-money-lives-with-a-processor-credits-stay-a-project-meter.md +4 -0
- package/docs/api/openapi.json +369 -6
- package/docs/api-reference.md +19 -4
- package/docs/architecture.md +6 -0
- package/docs/copy-inventory.md +231 -204
- package/docs/copy-registry.json +488 -255
- package/docs/file-map.md +1 -0
- package/docs/module-api-changelog.md +4 -2
- package/docs/page-inventory.json +7 -1
- package/docs/page-readings.json +1132 -1103
- package/migrations/core_289_task_short_name.sql +34 -0
- package/modules/ai-studio/db.js +10 -0
- package/modules/ai-studio/gemini.js +73 -5
- package/modules/ai-studio/openrouter.js +17 -2
- package/modules/ai-studio/routes/ai-studio.js +7 -1
- package/modules/ai-studio/studio.js +8 -0
- package/modules/builder-settings/checkout-freshness.js +71 -0
- package/modules/builder-settings/routes/settings.js +26 -0
- package/modules/builder-settings/settings.js +2 -0
- package/modules/economy/migrations/economy_002_project_account.sql +83 -0
- package/modules/economy/module.json +1 -1
- package/modules/economy/project-account.js +166 -0
- package/modules/economy/routes/project-account.js +40 -0
- package/modules/hall-ui/public/ai-studio.css +3 -0
- package/modules/hall-ui/public/ai-studio.html +2 -1
- package/modules/hall-ui/public/ai-studio.js +72 -1
- package/modules/hall-ui/public/atlas.html +1 -1
- package/modules/hall-ui/public/blockers.html +1 -1
- package/modules/hall-ui/public/board-room.html +1 -1
- package/modules/hall-ui/public/city.html +1 -1
- package/modules/hall-ui/public/collab.html +1 -1
- package/modules/hall-ui/public/commands.html +1 -1
- package/modules/hall-ui/public/copy-desk.html +1 -1
- package/modules/hall-ui/public/deploy.html +1 -1
- package/modules/hall-ui/public/diagrams.html +1 -1
- package/modules/hall-ui/public/drachmae.html +1 -1
- package/modules/hall-ui/public/fleet.html +1 -1
- package/modules/hall-ui/public/gate.html +1 -1
- package/modules/hall-ui/public/github-chip.js +85 -0
- package/modules/hall-ui/public/goal-map.html +1 -1
- package/modules/hall-ui/public/goals.html +1 -1
- package/modules/hall-ui/public/government.html +1 -1
- package/modules/hall-ui/public/idea.html +1 -1
- package/modules/hall-ui/public/ideas.html +1 -1
- package/modules/hall-ui/public/index.html +1 -1
- package/modules/hall-ui/public/market-watch-item.html +2 -2
- package/modules/hall-ui/public/market-watch-item.js +10 -3
- package/modules/hall-ui/public/market-watch-item.states.json +41 -0
- package/modules/hall-ui/public/market-watch-lib.js +36 -5
- package/modules/hall-ui/public/market-watch-rules.html +1 -1
- package/modules/hall-ui/public/market-watch-settings.html +1 -1
- package/modules/hall-ui/public/market-watch.css +5 -0
- package/modules/hall-ui/public/market-watch.html +2 -2
- package/modules/hall-ui/public/modules.html +1 -1
- package/modules/hall-ui/public/my-agents.html +1 -1
- package/modules/hall-ui/public/my-agents.js +3 -0
- package/modules/hall-ui/public/panel.css +5 -0
- package/modules/hall-ui/public/primer.html +1 -1
- package/modules/hall-ui/public/profile.html +1 -1
- package/modules/hall-ui/public/project-settings.html +1 -1
- package/modules/hall-ui/public/ranks.html +1 -1
- package/modules/hall-ui/public/roadmap.html +1 -1
- package/modules/hall-ui/public/roster.html +1 -1
- package/modules/hall-ui/public/sessions.html +1 -1
- package/modules/hall-ui/public/settings-age.js +65 -0
- package/modules/hall-ui/public/settings.html +27 -1
- package/modules/hall-ui/public/settings.js +1 -0
- package/modules/hall-ui/public/shell.js +22 -22
- package/modules/hall-ui/public/street.html +1 -1
- package/modules/hall-ui/public/studio.html +1 -1
- package/modules/hall-ui/public/style.css +28 -0
- package/modules/hall-ui/public/task.html +1 -1
- package/modules/hall-ui/public/thinking.html +1 -1
- package/modules/hall-ui/public/tweak-editor.html +1 -1
- package/modules/hall-ui/public/watch.html +1 -1
- package/modules/hall-ui/public/work.html +1 -1
- package/modules/ideas/blockers.js +13 -7
- package/modules/lifecycle/db-claim-reads.js +1 -1
- package/modules/lifecycle/db-ship.js +2 -2
- package/modules/lifecycle/db-tasks.js +4 -4
- package/modules/lifecycle/migrations/lifecycle_019_pay_type_gates.sql +50 -0
- package/modules/lifecycle/module.json +1 -0
- package/modules/lifecycle/pay-type-gates.js +180 -0
- package/modules/lifecycle/publish-reconciler.js +31 -1
- package/modules/lifecycle/rebase-gate.js +3 -2
- package/modules/lifecycle/routes/claims.js +3 -2
- package/modules/lifecycle/routes/pay-type-gates.js +59 -0
- package/modules/lifecycle/routes/project-settings.js +4 -0
- package/modules/lifecycle/routes/task-write-routes.js +43 -0
- package/modules/lifecycle/ship-card.js +4 -1
- package/modules/lifecycle/task-name-db.js +55 -0
- package/modules/lifecycle/task-name.js +140 -0
- package/modules/market-watch/db.js +10 -1
- package/modules/market-watch/migrations/market_watch_004_entry_corrections.sql +20 -0
- package/modules/market-watch/pages.js +20 -1
- package/modules/market-watch/proposals.js +57 -7
- package/modules/market-watch/routes/pages.js +2 -0
- package/modules/market-watch/routes/rules.js +4 -0
- package/modules/platform-identity/terms-acceptance.js +9 -9
- package/modules/public-landing/public/account-google.states.json +14 -0
- package/modules/public-landing/public/assets/cosmos.css +12 -0
- package/modules/public-landing/public/assets/hub-bar.js +44 -1
- package/modules/public-landing/public/privacy.html +5 -1
- package/modules/public-landing/public/projects-google.states.json +14 -0
- package/modules/public-landing/public/projects.html +39 -0
- package/modules/public-landing/public/terms.html +12 -2
- package/modules/ui-design/kit/fixtures/me-google.json +10 -0
- package/modules/ui-design/kit/fixtures/me-sign-in-methods-google.json +4 -0
- package/modules/ui-design/kit/serve.js +7 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +55 -1
- package/scripts/bongos/claim.js +4 -2
- package/scripts/bongos/land-watch.js +1 -0
- package/scripts/bongos/report-checkout.js +30 -0
- package/scripts/bongos/run-unit-tests.js +11 -0
- package/scripts/bongos/sessions-board.js +264 -0
- package/scripts/bongos/ship-finish.js +1 -0
- package/scripts/bongos/ship-flow.js +5 -2
- package/scripts/bongos/strand-lease.js +5 -3
- package/scripts/bongos/strand-watch.js +38 -7
- package/scripts/gds/report-checkout.js +7 -0
- package/scripts/gds/sessions-board.js +7 -0
- package/src/bongos/agent-standing-rules.js +13 -3
- package/src/bongos/routes/me.js +7 -1
- package/src/module-api.js +1 -1
- package/tests/agent_standing_rules.mjs +18 -1
- package/tests/ai_studio_billing.mjs +13 -0
- package/tests/ai_studio_gemini_models.mjs +81 -0
- package/tests/ai_studio_requests.mjs +24 -0
- package/tests/blocker_detail_hydration.mjs +5 -3
- package/tests/blocker_tasks_named_db.mjs +74 -0
- package/tests/claim_gate_ci_unblock.mjs +1 -1
- package/tests/github_chip.mjs +217 -0
- package/tests/hall_ai_studio.mjs +129 -5
- package/tests/host_topology_skips.mjs +1 -0
- package/tests/hub_account_page.mjs +3 -2
- package/tests/landing_legal_pages.mjs +3 -2
- package/tests/market_watch_data_db.mjs +6 -0
- package/tests/market_watch_pages.mjs +87 -2
- package/tests/market_watch_rules.mjs +101 -5
- package/tests/market_watch_rules_db.mjs +42 -0
- package/tests/nav_permission_atoms.mjs +1 -1
- package/tests/pay_terms.mjs +8 -0
- package/tests/pay_type_gates.mjs +234 -0
- package/tests/pay_type_gates_db.mjs +84 -0
- package/tests/project_account.mjs +125 -0
- package/tests/project_account_db.mjs +97 -0
- package/tests/self_healing_start.mjs +162 -0
- package/tests/sessions_board.mjs +153 -0
- package/tests/settings_age.mjs +110 -0
- package/tests/stop_refcheck.mjs +40 -0
- package/tests/task_name.mjs +237 -0
- package/tests/task_name_db.mjs +79 -0
- package/tests/terms_acceptance.mjs +25 -3
- 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 ?
|
|
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
|
-
|
|
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 {}
|
|
27
|
-
//
|
|
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
|
|
223
|
-
|
|
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
|
|
52
|
-
//
|
|
53
|
-
//
|
|
54
|
-
//
|
|
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-
|
|
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
|
|
100
|
-
//
|
|
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;}
|