@bongos/core 1.21.167 → 1.21.169
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 +157 -72
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +8 -0
- package/clients/bongos-client/index.cjs +8 -0
- package/clients/bongos-client/index.d.ts +13 -0
- package/clients/bongos-client/index.mjs +8 -0
- package/docs/api/openapi.json +308 -5
- package/docs/api-reference.md +7 -3
- package/docs/architecture.md +2 -0
- package/docs/copy-inventory.md +21 -20
- package/docs/copy-registry.json +34 -25
- package/docs/module-api-changelog.md +5 -1
- package/docs/modules-contract.md +2 -1
- package/docs/page-inventory.json +3 -0
- package/docs/page-readings.json +430 -430
- package/modules/autonomy/governor-docket.js +10 -5
- package/modules/builder-settings/builder-needs.js +29 -3
- package/modules/copy-desk/guarded-lines.js +89 -0
- package/modules/copy-desk/guarded-lines.json +14 -0
- package/modules/copy-desk/module.json +2 -2
- package/modules/copy-desk/page-status.js +77 -7
- package/modules/copy-desk/pages.js +4 -0
- package/modules/copy-desk/pollers/stuck-page-digest.js +34 -0
- package/modules/copy-desk/routes/copy-desk.js +8 -0
- package/modules/copy-desk/stuck-tweaks.js +79 -0
- package/modules/copy-desk/tests/copy_no_cms.mjs +6 -1
- package/modules/copy-desk/tests/copy_page_status.mjs +2 -2
- package/modules/government/asks-db.js +38 -2
- package/modules/government/catalog.js +1 -1
- package/modules/government/docket.js +20 -2
- package/modules/government/migrations/government_032_ask_flags.sql +35 -0
- package/modules/government/routes/government.js +43 -1
- package/modules/hall-ui/public/approval-queue-lib.js +4 -0
- package/modules/hall-ui/public/approval-queue.js +1 -0
- package/modules/hall-ui/public/city-decide.js +642 -0
- package/modules/hall-ui/public/city-draw.js +14 -3
- package/modules/hall-ui/public/city-switchboard.js +141 -0
- package/modules/hall-ui/public/city.css +176 -10
- package/modules/hall-ui/public/city.html +8 -3
- package/modules/hall-ui/public/city.js +185 -38
- package/modules/hall-ui/public/tweak-editor-lib.js +21 -2
- package/modules/hall-ui/public/tweak-editor.css +2 -0
- package/modules/hall-ui/public/tweak-editor.js +3 -1
- package/modules/lifecycle/db-claims.js +20 -5
- package/modules/lifecycle/rebase-gate.js +78 -0
- package/modules/lifecycle/routes/claims.js +10 -2
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +28 -0
- package/scripts/gds/ask.js +68 -1
- package/scripts/gds/claim.js +6 -1
- package/scripts/gds/copy-apply.js +2 -0
- package/src/bongos/module-promotion.js +219 -0
- package/src/bongos/module-store.js +47 -9
- package/src/bongos/route-rank-check.js +7 -0
- package/src/bongos/routes/me.js +11 -0
- package/src/bongos/routes/modules.js +72 -10
- package/src/module-api.js +1 -1
- package/src/platform-server.js +5 -0
- package/tests/claim_gate_ci_unblock.mjs +4 -0
- package/tests/claim_gate_rebase.mjs +56 -3
- package/tests/copy_desk_page_editor.mjs +1 -1
- package/tests/copy_desk_page_submit.mjs +42 -0
- package/tests/copy_guarded_lines.mjs +54 -0
- package/tests/copy_stuck_page_tweaks.mjs +118 -0
- package/tests/government_ask_flags.mjs +80 -0
- package/tests/government_seed.mjs +1 -0
- package/tests/government_treasury.mjs +1 -0
- package/tests/governor_docket.mjs +1 -1
- package/tests/hall_approval_queue.mjs +15 -0
- package/tests/hall_city.mjs +5 -4
- package/tests/hall_city_living.mjs +118 -12
- package/tests/hall_city_rewording.mjs +51 -0
- package/tests/hall_city_switchboard.mjs +63 -0
- package/tests/hall_gov_real_site.mjs +2 -2
- package/tests/hall_street_deck.mjs +6 -4
- package/tests/hall_street_depth.mjs +1 -1
- package/tests/hall_street_desk.mjs +5 -3
- package/tests/hall_tweak_editor.mjs +26 -0
- package/tests/module_channel_install.mjs +112 -0
- package/tests/module_promotion.mjs +290 -0
- package/tests/module_upgrade_updates.mjs +2 -2
- package/tests/rebase_gate_set_aside.mjs +73 -0
|
@@ -46,7 +46,9 @@ function fenceDocketMatters(project, mayManage) {
|
|
|
46
46
|
return [{
|
|
47
47
|
id: 'fence',
|
|
48
48
|
rule: 'decide',
|
|
49
|
-
state
|
|
49
|
+
// a standing state, not a to-do (owner, 2026-10-23): the switch lives on the
|
|
50
|
+
// Automation switchboard (automation.switch desk), so it never counts as on you
|
|
51
|
+
state: 'settled',
|
|
50
52
|
title,
|
|
51
53
|
short: 'the switch',
|
|
52
54
|
where,
|
|
@@ -77,11 +79,14 @@ const PAUSE_REASONS = Object.freeze([
|
|
|
77
79
|
|
|
78
80
|
// The Automation desks (task 1004559): rows the holder of autonomy.fence.manage
|
|
79
81
|
// acts on in place. Pure; exported for tests.
|
|
80
|
-
// The switch
|
|
82
|
+
// The switch, in either position: the Automation switchboard's master row.
|
|
81
83
|
function switchDeskRows(project) {
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
84
|
+
if (!project) return [];
|
|
85
|
+
const goals = Array.isArray(project.goals) ? project.goals : [];
|
|
86
|
+
if (project.enabled === false) {
|
|
87
|
+
return [{ id: 'switch', name: 'The switch', fact: 'off', facts: [project.paused_reason ? String(project.paused_reason).trim() : 'AI building: off'], opened_at: project.updated_at || null, ref: { type: 'autonomy_fence', id: 'project' }, acts: [{ key: 'switch_on' }] }];
|
|
88
|
+
}
|
|
89
|
+
return [{ id: 'switch', name: 'The switch', fact: `on · ${goals.length} ${goals.length === 1 ? 'goal' : 'goals'}`, facts: [goals.length ? 'AI building: on' : 'AI building: on, but no goal is allowed yet'], opened_at: project.updated_at || null, ref: { type: 'autonomy_fence', id: 'project' }, acts: [{ key: 'switch_off', options: OFF_REASONS }] }];
|
|
85
90
|
}
|
|
86
91
|
// One row per allowed goal (first in line, or not allowed any more), and one to
|
|
87
92
|
// allow another of the open goals the screen already reads.
|
|
@@ -245,7 +245,32 @@ function mingleIntroductionsNeed(ctx) {
|
|
|
245
245
|
};
|
|
246
246
|
}
|
|
247
247
|
|
|
248
|
-
|
|
248
|
+
// task 1004777: a page tweak that has sat too long, one line PER PAGE. The
|
|
249
|
+
// copy-desk port (copy-desk.stuckPageTweaksFor) has already chosen who is told:
|
|
250
|
+
// a review left waiting goes to its artist, everything else to Metic+. Each item
|
|
251
|
+
// says where the page is and for how long, and links to the Studio.
|
|
252
|
+
// ctx.stuckPageTweaks — [{ page_id, title, state_word, days }]; absent = none.
|
|
253
|
+
function stuckPageTweakNeeds(ctx) {
|
|
254
|
+
const list = Array.isArray(ctx.stuckPageTweaks) ? ctx.stuckPageTweaks : [];
|
|
255
|
+
return list.map((s) => {
|
|
256
|
+
// A templated title ({{productName}}…) falls back to the page id. The same
|
|
257
|
+
// sniff sits in copy-desk page-status.js stuckDigestText, duplicated on
|
|
258
|
+
// purpose: this module cannot require copy-desk (ADR 0093).
|
|
259
|
+
const name = String(s.title || '').includes('{{') ? s.page_id : (s.title || s.page_id);
|
|
260
|
+
const days = Number.isFinite(Number(s.days)) && s.days != null ? Number(s.days) : null;
|
|
261
|
+
return {
|
|
262
|
+
id: `page_tweak_stuck:${s.page_id}`,
|
|
263
|
+
title: `Page tweak stuck: ${name}`,
|
|
264
|
+
what: 'A page tweak has sat in one place longer than it should.',
|
|
265
|
+
state: 'action_needed',
|
|
266
|
+
severity: 'info',
|
|
267
|
+
message: `${s.state_word}${days != null ? ` for ${days} day${days === 1 ? '' : 's'}` : ''}.`,
|
|
268
|
+
action: { label: 'Open the Studio', href: '/studio' },
|
|
269
|
+
};
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const NEEDS = [artKeyNeed, helpRequestsNeed, taskRecommendationsNeed, boardVotesNeed, mingleIntroductionsNeed, stuckPageTweakNeeds];
|
|
249
274
|
|
|
250
275
|
// Priority WITHIN a state bucket (task 1003738). computeNeeds used to sort by
|
|
251
276
|
// state alone with a stable sort, so the NEEDS array order silently decided
|
|
@@ -275,6 +300,7 @@ const NEED_PRIORITY = {
|
|
|
275
300
|
art_key: 2,
|
|
276
301
|
task_recommendations: 3,
|
|
277
302
|
mingle_introductions: 4,
|
|
303
|
+
// page_tweak_stuck:<page> ids carry no weight entry and sort last in their bucket.
|
|
278
304
|
};
|
|
279
305
|
|
|
280
306
|
// ---- founding grace (task 1002739 / direction §5.6, idea 1000701) -----------
|
|
@@ -333,7 +359,7 @@ function foundingWelcomeNeed(ctx) {
|
|
|
333
359
|
// drop. Home's "From the system" inbox does exactly that.
|
|
334
360
|
function computeNeeds(ctx = {}) {
|
|
335
361
|
const grace = foundingGraceActive(ctx);
|
|
336
|
-
let items = NEEDS.
|
|
362
|
+
let items = NEEDS.flatMap((fn) => fn(ctx)).filter(Boolean);
|
|
337
363
|
if (grace) {
|
|
338
364
|
items = items.map((n) => n.state !== 'action_needed' ? n : {
|
|
339
365
|
...n,
|
|
@@ -359,7 +385,7 @@ module.exports = {
|
|
|
359
385
|
computeNeeds,
|
|
360
386
|
// exported for tests / direct reuse
|
|
361
387
|
artKeyNeed,
|
|
362
|
-
helpRequestsNeed, taskRecommendationsNeed, boardVotesNeed, mingleIntroductionsNeed,
|
|
388
|
+
helpRequestsNeed, taskRecommendationsNeed, boardVotesNeed, mingleIntroductionsNeed, stuckPageTweakNeeds,
|
|
363
389
|
foundingGraceActive,
|
|
364
390
|
FOUNDING_GRACE_DAYS,
|
|
365
391
|
SETTINGS_PATH,
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// guarded-lines.js — page lines that must keep their meaning (task 1004776).
|
|
4
|
+
//
|
|
5
|
+
// WHY. Some page lines carry a promise the product must keep: the Account page's
|
|
6
|
+
// Private option must say the page is hidden from everyone, its owner included
|
|
7
|
+
// (tests/account_privacy_flags.mjs asserts it). An artist rewrote it to "you are a
|
|
8
|
+
// ninja." and only found out after approving, when CI went red. So the editor says
|
|
9
|
+
// so WHILE they write: the line carries its "must still say" sentence, and a
|
|
10
|
+
// submit whose new text drops what the note requires is WARNED — never refused,
|
|
11
|
+
// the artist may have a better wording — and the warning rides into the batch, so
|
|
12
|
+
// the applier and the Approval queue show it too.
|
|
13
|
+
//
|
|
14
|
+
// The list is guarded-lines.json beside this file. A guarded line is found on its
|
|
15
|
+
// page by its FILE plus every must_contain phrase (case-insensitive), not by a
|
|
16
|
+
// reading key or string id: both of those move when the line is rewritten, and a
|
|
17
|
+
// rewrite that keeps the promise must keep the guard. Pure; the JSON is read once.
|
|
18
|
+
|
|
19
|
+
const fs = require('node:fs');
|
|
20
|
+
const path = require('node:path');
|
|
21
|
+
|
|
22
|
+
const LIST_PATH = path.join(__dirname, 'guarded-lines.json');
|
|
23
|
+
let cached = null;
|
|
24
|
+
function loadList() {
|
|
25
|
+
if (!cached) {
|
|
26
|
+
try { cached = JSON.parse(fs.readFileSync(LIST_PATH, 'utf8')); } catch { cached = { lines: [] }; }
|
|
27
|
+
}
|
|
28
|
+
return cached;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const lower = (s) => String(s == null ? '' : s).toLowerCase();
|
|
32
|
+
|
|
33
|
+
function missingPhrases(entry, text) {
|
|
34
|
+
const t = lower(text);
|
|
35
|
+
return (entry.must_contain || []).filter((p) => !t.includes(lower(p)));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// The guard entries for one page.
|
|
39
|
+
function guardsFor(pageId, list = loadList()) {
|
|
40
|
+
return ((list && list.lines) || []).filter((e) => e && e.page_id === pageId);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// guardForLine(pageId, line) -> entry | null. `line` is a reading line
|
|
44
|
+
// ({ text, file }); a line with no file matches on its text alone.
|
|
45
|
+
function guardForLine(pageId, line, list = loadList()) {
|
|
46
|
+
if (!line) return null;
|
|
47
|
+
for (const e of guardsFor(pageId, list)) {
|
|
48
|
+
if (e.file && line.file && e.file !== line.file) continue;
|
|
49
|
+
if (!missingPhrases(e, line.text).length) return e;
|
|
50
|
+
}
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// guardWarnings({ pageId, readingPage, lines }) -> [{ key, must_still_say, missing, guarded_by }]
|
|
55
|
+
// `lines` are the submitted lines ({ key, after, remove? }); each is looked up on
|
|
56
|
+
// the CURRENT reading by key, and warned when that reading line is guarded and the
|
|
57
|
+
// new text drops a required phrase (a removal drops all of them).
|
|
58
|
+
function guardWarnings({ pageId, readingPage, lines }, list = loadList()) {
|
|
59
|
+
const byKey = new Map(((readingPage && readingPage.lines) || []).map((l) => [l.key, l]));
|
|
60
|
+
const out = [];
|
|
61
|
+
for (const l of lines || []) {
|
|
62
|
+
const at = byKey.get(l.key);
|
|
63
|
+
const g = at ? guardForLine(pageId, at, list) : null;
|
|
64
|
+
if (!g) continue;
|
|
65
|
+
const missing = l.remove ? (g.must_contain || []).slice() : missingPhrases(g, l.after);
|
|
66
|
+
if (missing.length) out.push({ key: l.key, must_still_say: g.must_still_say, missing, guarded_by: g.guarded_by || null });
|
|
67
|
+
}
|
|
68
|
+
return out;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// checkList(readings) -> [{ page_id, problem }] — every entry must find exactly
|
|
72
|
+
// one line on its page in docs/page-readings.json (the honesty check).
|
|
73
|
+
function checkList(readings, list = loadList()) {
|
|
74
|
+
const pages = new Map(((readings && readings.pages) || []).map((p) => [p.id, p]));
|
|
75
|
+
const problems = [];
|
|
76
|
+
for (const e of (list && list.lines) || []) {
|
|
77
|
+
const page = pages.get(e.page_id);
|
|
78
|
+
if (!page) { problems.push({ page_id: e.page_id, problem: 'no such page in docs/page-readings.json' }); continue; }
|
|
79
|
+
if (!e.must_still_say || !Array.isArray(e.must_contain) || !e.must_contain.length) { problems.push({ page_id: e.page_id, problem: 'needs must_still_say and at least one must_contain phrase' }); continue; }
|
|
80
|
+
const hits = new Set(page.lines.filter((l) => guardForLine(e.page_id, l, { lines: [e] })).map((l) => l.text));
|
|
81
|
+
if (hits.size !== 1) problems.push({ page_id: e.page_id, problem: `matches ${hits.size} distinct lines (want exactly 1): ${e.must_contain.join(' + ')}` });
|
|
82
|
+
}
|
|
83
|
+
return problems;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Test seam: a fixture list for the route-level tests; null goes back to the file.
|
|
87
|
+
function _setList(list) { cached = list; }
|
|
88
|
+
|
|
89
|
+
module.exports = { _setList, LIST_PATH, loadList, guardsFor, guardForLine, guardWarnings, checkList, missingPhrases };
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schema": 1,
|
|
3
|
+
"task": 1004776,
|
|
4
|
+
"how": "Page lines that carry a promise the product must keep. A line is found on its page by its file plus every must_contain phrase (case-insensitive), so a rewrite that keeps the promise keeps the guard. The tweak editor shows must_still_say under the line, and a submit whose new text drops a phrase is warned (never refused). guarded_by names the test that asserts the meaning. tests/copy_guarded_lines.mjs checks every entry still finds exactly one line in docs/page-readings.json.",
|
|
5
|
+
"lines": [
|
|
6
|
+
{
|
|
7
|
+
"page_id": "landing:account",
|
|
8
|
+
"file": "modules/public-landing/public/account.html",
|
|
9
|
+
"must_contain": ["Private hides the page from everyone", "including you"],
|
|
10
|
+
"must_still_say": "Private hides the page from everyone, including its owner.",
|
|
11
|
+
"guarded_by": "tests/account_privacy_flags.mjs"
|
|
12
|
+
}
|
|
13
|
+
]
|
|
14
|
+
}
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
"category": { "parent": "quality" },
|
|
8
8
|
"default": true,
|
|
9
9
|
"maintenance": { "status": "core-maintained" },
|
|
10
|
-
"contributes": { "routes": ["copy-desk", "copy-desk-artist"], "migrations": true, "skills": ["tweak"] },
|
|
11
|
-
"provides": ["copy-desk.artistStats"],
|
|
10
|
+
"contributes": { "routes": ["copy-desk", "copy-desk-artist"], "migrations": true, "skills": ["tweak"], "pollers": ["stuck-page-digest"] },
|
|
11
|
+
"provides": ["copy-desk.artistStats", "copy-desk.stuckPageTweaksFor"],
|
|
12
12
|
"consumes": ["lifecycle"]
|
|
13
13
|
}
|
|
@@ -33,6 +33,7 @@
|
|
|
33
33
|
'use strict';
|
|
34
34
|
|
|
35
35
|
const pages = require('./pages');
|
|
36
|
+
const guarded = require('./guarded-lines');
|
|
36
37
|
|
|
37
38
|
const TERMINAL = new Set(['shipped', 'abandoned']);
|
|
38
39
|
|
|
@@ -277,7 +278,8 @@ function orderedSurfaces(inventory) {
|
|
|
277
278
|
// (applied rounds only) stayed empty: the artist's finished work looked lost.
|
|
278
279
|
// This is every page with an open round, where it is, since when (the round's
|
|
279
280
|
// last update), and whether it has sat there too long. Waiting for review is
|
|
280
|
-
// the artist's own move and a draft is theirs to finish
|
|
281
|
+
// the artist's own move and a draft is theirs to finish; a draft is never stuck,
|
|
282
|
+
// and a review is stuck only after 3 days (task 1004777, so its artist is told);
|
|
281
283
|
// a state the reads cannot name always needs a look.
|
|
282
284
|
// ---------------------------------------------------------------------------
|
|
283
285
|
|
|
@@ -287,7 +289,8 @@ const IN_FLIGHT = Object.freeze({
|
|
|
287
289
|
writing: { word: 'Being written', stuck_after: null },
|
|
288
290
|
submitted: { word: 'Submitted, waiting to be applied', stuck_after: 3 * DAY_MS },
|
|
289
291
|
applying: { word: 'Being applied', stuck_after: DAY_MS },
|
|
290
|
-
|
|
292
|
+
// task 1004777: a review left 3 days is stuck too, so its artist is told on Home.
|
|
293
|
+
waiting_for_artist: { word: 'Waiting for your review', stuck_after: 3 * DAY_MS },
|
|
291
294
|
landing: { word: 'Approved, going live', stuck_after: DAY_MS },
|
|
292
295
|
unknown: { word: 'Needs a look', stuck_after: 0 },
|
|
293
296
|
});
|
|
@@ -313,12 +316,64 @@ function inFlightFor(list, byPage, nowMs) {
|
|
|
313
316
|
since,
|
|
314
317
|
stuck: kind.stuck_after != null && (kind.stuck_after === 0 || (age != null && age > kind.stuck_after)),
|
|
315
318
|
holder: o.holder,
|
|
319
|
+
artist_id: r.artist_id != null ? String(r.artist_id) : null,
|
|
316
320
|
});
|
|
317
321
|
}
|
|
318
322
|
const rank = (s) => { const i = IN_FLIGHT_ORDER.indexOf(s); return i === -1 ? 0 : i; };
|
|
319
323
|
return out.sort((a, b) => (b.stuck - a.stuck) || rank(a.state) - rank(b.state) || String(a.since || '').localeCompare(String(b.since || '')));
|
|
320
324
|
}
|
|
321
325
|
|
|
326
|
+
// ---------------------------------------------------------------------------
|
|
327
|
+
// Stuck page tweaks, told to someone (task 1004777). The Studio's in-flight list
|
|
328
|
+
// marks a stuck round, but only for whoever opens the Studio, and 11 rounds sat
|
|
329
|
+
// from late September unseen. The SAME list and stuck rule, derived from the
|
|
330
|
+
// ledger (no status table), now feeds two pushes: a Needs-attention line on Home
|
|
331
|
+
// for the person it waits on, and a once-a-day Discord digest.
|
|
332
|
+
//
|
|
333
|
+
// Who it waits on: a round waiting for review waits on ITS ARTIST; every other
|
|
334
|
+
// stuck state (submitted and not applied, being applied, approved but not live,
|
|
335
|
+
// a state the reads cannot name) waits on the builders who can apply and land
|
|
336
|
+
// work, Metic and up.
|
|
337
|
+
// ---------------------------------------------------------------------------
|
|
338
|
+
|
|
339
|
+
const APPLIER_RANKS = new Set(['metic', 'archon']);
|
|
340
|
+
|
|
341
|
+
function stuckPageTweaks({ inventory, tasks, now = Date.now() }) {
|
|
342
|
+
const { in_flight: inFlight } = composePages({ inventory, readings: null, tasks, now });
|
|
343
|
+
const nowMs = now instanceof Date ? now.getTime() : Number(now);
|
|
344
|
+
return inFlight.filter((f) => f.stuck).map((f) => {
|
|
345
|
+
const ms = f.since ? nowMs - Date.parse(f.since) : null;
|
|
346
|
+
return {
|
|
347
|
+
page_id: f.page_id,
|
|
348
|
+
title: f.title,
|
|
349
|
+
task_id: f.task_id,
|
|
350
|
+
state: f.state,
|
|
351
|
+
state_word: f.state_word,
|
|
352
|
+
since: f.since,
|
|
353
|
+
days: ms == null ? null : Math.max(0, Math.floor(ms / DAY_MS)),
|
|
354
|
+
waits_on: f.state === 'waiting_for_artist' ? 'artist' : 'appliers',
|
|
355
|
+
artist_id: f.artist_id,
|
|
356
|
+
};
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// The stuck tweaks one builder should be told about on Home.
|
|
361
|
+
function stuckPageTweaksFor({ builderId, rank, stuck }) {
|
|
362
|
+
const applier = APPLIER_RANKS.has(String(rank || '').toLowerCase());
|
|
363
|
+
return (stuck || []).filter((s) => (s.waits_on === 'artist'
|
|
364
|
+
? s.artist_id != null && String(s.artist_id) === String(builderId)
|
|
365
|
+
: applier));
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// The Discord digest: page names and where each sits, never the words.
|
|
369
|
+
function stuckDigestText(stuck) {
|
|
370
|
+
if (!stuck || !stuck.length) return null;
|
|
371
|
+
const plain = (s) => String(s || '').replace(/\{\{[^}]*\}\}/g, '').replace(/[`*_~|<>@]/g, '').replace(/\s+/g, ' ').trim();
|
|
372
|
+
const name = (s) => (String(s.title || '').includes('{{') ? s.page_id : plain(s.title) || s.page_id);
|
|
373
|
+
const lines = stuck.map((s) => `- ${name(s)}: ${s.state_word.toLowerCase()}${s.days != null ? `, ${s.days} day${s.days === 1 ? '' : 's'}` : ''}`);
|
|
374
|
+
return [`Page tweaks stuck (${stuck.length}). Open the Studio to move them:`, ...lines].join('\n');
|
|
375
|
+
}
|
|
376
|
+
|
|
322
377
|
// GET /copy-desk/pages — every page's status, count and drift, plus the
|
|
323
378
|
// per-surface rollup (Artist Review Status) and the pages in flight.
|
|
324
379
|
function composePages({ inventory, readings, tasks, now = Date.now() }) {
|
|
@@ -605,9 +660,12 @@ function composeNext({ inventory, readings, tasks, asks, builderId, alternatives
|
|
|
605
660
|
// The margin note a line carries in the editor (ADR 0341 D4, TW10): shared
|
|
606
661
|
// shell text changes on every page, and a line the applier cannot place goes
|
|
607
662
|
// to an engineer at submit (pages.goesToEngineer, the submit's own rule).
|
|
608
|
-
|
|
663
|
+
// task 1004776: a third kind, `guarded` — the line carries a promise the product
|
|
664
|
+
// must keep (guarded-lines.json); the line also carries `must_still_say`.
|
|
665
|
+
const EDITOR_NOTES = Object.freeze({ shared: 'changes this on every page', engineer: 'goes to an engineer', guarded: 'must still say' });
|
|
609
666
|
|
|
610
|
-
function editorNote(line) {
|
|
667
|
+
function editorNote(line, pageId) {
|
|
668
|
+
if (pageId && guarded.guardForLine(pageId, line)) return 'guarded';
|
|
611
669
|
if (pages.goesToEngineer(line)) return 'engineer';
|
|
612
670
|
return line.placement === 'shared' ? 'shared' : null;
|
|
613
671
|
}
|
|
@@ -622,9 +680,13 @@ function editorNote(line) {
|
|
|
622
680
|
// page has not been read); `round` is the page's open round as the lifecycle
|
|
623
681
|
// port lists it, or null.
|
|
624
682
|
function composeEditor({ page, readingPage, round, builderId }) {
|
|
625
|
-
const lines = ((readingPage && readingPage.lines) || []).map((l) =>
|
|
626
|
-
|
|
627
|
-
|
|
683
|
+
const lines = ((readingPage && readingPage.lines) || []).map((l) => {
|
|
684
|
+
const g = guarded.guardForLine(page.id, l);
|
|
685
|
+
return {
|
|
686
|
+
key: l.key, section: l.section, text: l.text, placement: l.placement, note: editorNote(l, page.id),
|
|
687
|
+
...(g ? { must_still_say: g.must_still_say, must_contain: (g.must_contain || []).slice() } : {}),
|
|
688
|
+
};
|
|
689
|
+
});
|
|
628
690
|
const readingHash = readingPage ? readingPage.reading_hash || null : null;
|
|
629
691
|
let draft = null;
|
|
630
692
|
let olderReading = false;
|
|
@@ -693,6 +755,9 @@ function approvalFor({ page, pageId, rounds, round, builderId, isArtist }) {
|
|
|
693
755
|
if (applied.ok) for (const r of applied.applied.refused || []) if (r && r.key) refusedBy.set(String(r.key), String(r.code || 'refused'));
|
|
694
756
|
const pinnedBy = new Map();
|
|
695
757
|
if (applied.ok) for (const p of applied.applied.pinned_tests || []) if (p && p.key && Array.isArray(p.tests)) pinnedBy.set(String(p.key), p.tests.map(String));
|
|
758
|
+
// task 1004776: the submit's guarded-line warnings, by line.
|
|
759
|
+
const warnedBy = new Map();
|
|
760
|
+
if (batch.ok) for (const w of batch.batch.warnings || []) if (w && w.key) warnedBy.set(String(w.key), String(w.must_still_say || ''));
|
|
696
761
|
const lines = batch.ok ? batch.batch.lines.map((l) => ({
|
|
697
762
|
key: l.key,
|
|
698
763
|
section: l.section == null ? null : l.section,
|
|
@@ -701,6 +766,7 @@ function approvalFor({ page, pageId, rounds, round, builderId, isArtist }) {
|
|
|
701
766
|
placement: l.placement || null,
|
|
702
767
|
refused: refusedBy.get(String(l.key)) || null,
|
|
703
768
|
...(pinnedBy.has(String(l.key)) ? { pinned_by: pinnedBy.get(String(l.key)) } : {}),
|
|
769
|
+
...(warnedBy.has(String(l.key)) ? { must_still_say: warnedBy.get(String(l.key)) } : {}),
|
|
704
770
|
})) : [];
|
|
705
771
|
const shipped = (rounds || []).filter((r) => r.status === 'shipped');
|
|
706
772
|
const now = countsFor(rounds);
|
|
@@ -823,4 +889,8 @@ module.exports = {
|
|
|
823
889
|
lastRoundShippedAt,
|
|
824
890
|
composeTally,
|
|
825
891
|
composeArtistStats,
|
|
892
|
+
// task 1004777: stuck page tweaks, pushed to Home and Discord.
|
|
893
|
+
stuckPageTweaks,
|
|
894
|
+
stuckPageTweaksFor,
|
|
895
|
+
stuckDigestText,
|
|
826
896
|
};
|
|
@@ -145,6 +145,10 @@ function composeBatchBlock(b) {
|
|
|
145
145
|
file: l.file == null ? null : l.file, line: l.line == null ? null : l.line, string_id: l.string_id == null ? null : l.string_id,
|
|
146
146
|
}));
|
|
147
147
|
out.unplaced_tasks = Array.isArray(b.unplaced_tasks) ? b.unplaced_tasks : [];
|
|
148
|
+
// task 1004776: a guarded line whose new text drops what it must still say.
|
|
149
|
+
if (Array.isArray(b.warnings) && b.warnings.length) {
|
|
150
|
+
out.warnings = b.warnings.map((w) => ({ key: w.key, must_still_say: w.must_still_say, missing: (w.missing || []).map(String), guarded_by: w.guarded_by || null }));
|
|
151
|
+
}
|
|
148
152
|
return fenceBlock(FENCES.batch, out);
|
|
149
153
|
}
|
|
150
154
|
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// modules/copy-desk/pollers/stuck-page-digest.js — the once-a-day Discord digest
|
|
4
|
+
// of stuck page tweaks (task 1004777). Loader-started (contributes.pollers); the
|
|
5
|
+
// rule and the post live in ../stuck-tweaks.js. Checks hourly; posts at most once
|
|
6
|
+
// per UTC day, after COPY_STUCK_DIGEST_HOUR_UTC (default 9), and only when a page
|
|
7
|
+
// is stuck. Best-effort: a failed read or post logs and waits for tomorrow.
|
|
8
|
+
// COPY_STUCK_DIGEST_DISABLED=1 turns it off.
|
|
9
|
+
|
|
10
|
+
const api = require('../../../src/module-api');
|
|
11
|
+
const stuck = require('../stuck-tweaks');
|
|
12
|
+
|
|
13
|
+
const log = api.logger('copy-desk');
|
|
14
|
+
const INTERVAL_MS = Number(process.env.COPY_STUCK_DIGEST_MS) || 60 * 60_000;
|
|
15
|
+
const state = { lastDay: null };
|
|
16
|
+
let timer = null;
|
|
17
|
+
|
|
18
|
+
function tick() {
|
|
19
|
+
stuck.runDigest({ state })
|
|
20
|
+
.then((r) => { if (r.posted) log.info(`[stuck-page-digest] posted ${r.text.split('\n').length - 1} stuck page tweak(s)`); })
|
|
21
|
+
.catch((e) => log.error(e, '[stuck-page-digest] digest failed'));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
module.exports = {
|
|
25
|
+
start() {
|
|
26
|
+
if (process.env.COPY_STUCK_DIGEST_DISABLED === '1' || timer) return;
|
|
27
|
+
timer = setInterval(tick, INTERVAL_MS);
|
|
28
|
+
if (timer.unref) timer.unref();
|
|
29
|
+
},
|
|
30
|
+
stop() {
|
|
31
|
+
if (timer) clearInterval(timer);
|
|
32
|
+
timer = null;
|
|
33
|
+
},
|
|
34
|
+
};
|
|
@@ -60,6 +60,7 @@ const proposals = require('../proposals');
|
|
|
60
60
|
const pageData = require('../page-data');
|
|
61
61
|
const pageStatus = require('../page-status');
|
|
62
62
|
const pages = require('../pages');
|
|
63
|
+
const guarded = require('../guarded-lines');
|
|
63
64
|
const docx = require('../docx');
|
|
64
65
|
const { waitingRoundOrFail, refuseDecision, nextWaiting } = require('../page-decisions');
|
|
65
66
|
const { tallyCreditRows, readArtistStats } = require('../artist-stats');
|
|
@@ -231,6 +232,7 @@ function alreadySubmitted(round, builderId, pageId) {
|
|
|
231
232
|
round: { task_id: String(round.id), state: 'submitted', status: 'ready' },
|
|
232
233
|
batch: { line_count: b.batch.lines.length, submitted_at: b.batch.submitted_at, reading_hash: b.batch.reading_hash },
|
|
233
234
|
unplaced_tasks: (b.batch.unplaced_tasks || []).map((id) => ({ task_id: String(id) })),
|
|
235
|
+
warnings: b.batch.warnings || [],
|
|
234
236
|
next: { path: '/copy-desk/next' },
|
|
235
237
|
};
|
|
236
238
|
}
|
|
@@ -371,6 +373,8 @@ module.exports = function buildCopyDeskRouter() {
|
|
|
371
373
|
if (!api.hasProvider('copy-desk.artistStats')) {
|
|
372
374
|
api.registerProvider('copy-desk.artistStats', readArtistStats);
|
|
373
375
|
}
|
|
376
|
+
// task 1004777: Home's Needs-attention reads stuck page tweaks through this port.
|
|
377
|
+
if (!api.hasProvider('copy-desk.stuckPageTweaksFor')) api.registerProvider('copy-desk.stuckPageTweaksFor', require('../stuck-tweaks').readStuckFor);
|
|
374
378
|
|
|
375
379
|
// Page asks resolve when the page's tweak ships (ADR 0341 D6). Re-registered
|
|
376
380
|
// on every router build (tests rebuild it), never stacked.
|
|
@@ -1299,6 +1303,9 @@ module.exports = function buildCopyDeskRouter() {
|
|
|
1299
1303
|
if (d.draft.page_id !== pageId) return { ok: false, code: 'wrong_page', detail: { page_id: pageId, draft_page_id: d.draft.page_id } };
|
|
1300
1304
|
const f = pages.freezeBatch({ readingPage, draft: d.draft, submittedAt });
|
|
1301
1305
|
if (!f.ok) return f;
|
|
1306
|
+
// task 1004776: warn (never refuse) on a guarded line that drops its promise;
|
|
1307
|
+
// the warning rides in the batch so the applier and the Approval queue see it.
|
|
1308
|
+
f.batch.warnings = guarded.guardWarnings({ pageId, readingPage, lines: d.draft.lines });
|
|
1302
1309
|
const probe = pages.replaceBlock(task.description, pages.FENCES.batch, '');
|
|
1303
1310
|
if (!probe.ok) return probe;
|
|
1304
1311
|
frozen = f;
|
|
@@ -1323,6 +1330,7 @@ module.exports = function buildCopyDeskRouter() {
|
|
|
1323
1330
|
ok: true, outcome: 'submitted', page_id: pageId,
|
|
1324
1331
|
round: { task_id: String(r.task.id), state: 'submitted', status: r.task.status },
|
|
1325
1332
|
batch: { line_count: frozen.batch.lines.length, submitted_at: submittedAt, reading_hash: frozen.batch.reading_hash },
|
|
1333
|
+
warnings: frozen.batch.warnings || [],
|
|
1326
1334
|
unplaced_tasks: r.unplaced_tasks.map((u) => ({ task_id: u.id, key: u.key, created: u.created })),
|
|
1327
1335
|
// "Next up" (the editor's closing card) reads the recommendation.
|
|
1328
1336
|
next: { path: '/copy-desk/next' },
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// stuck-tweaks.js — a page tweak that sits too long tells someone (task 1004777).
|
|
4
|
+
//
|
|
5
|
+
// The composer is page-status.js (stuckPageTweaks: the Studio's in-flight list
|
|
6
|
+
// and its stuck rule, derived from the ledger, no status table). This file is the
|
|
7
|
+
// I/O around it, kept out of routes/copy-desk.js, which sits at its line ratchet:
|
|
8
|
+
//
|
|
9
|
+
// • readStuckFor(builderId, rank) — the `copy-desk.stuckPageTweaksFor` port the
|
|
10
|
+
// kernel /me route reads for the Home Needs-attention inbox. Fails soft ([]).
|
|
11
|
+
// • the daily digest — at most one post per UTC day to the Discord ship channel
|
|
12
|
+
// (the `discord.post` port; no Discord, no post), page names only, never copy.
|
|
13
|
+
|
|
14
|
+
const api = require('../../src/module-api');
|
|
15
|
+
const pageData = require('./page-data');
|
|
16
|
+
const pageStatus = require('./page-status');
|
|
17
|
+
|
|
18
|
+
const DIGEST_CHANNEL = 'ship_feed';
|
|
19
|
+
const DIGEST_HOUR_UTC = Number(process.env.COPY_STUCK_DIGEST_HOUR_UTC) || 9;
|
|
20
|
+
|
|
21
|
+
async function readStuck({ now = Date.now(), resolve = (k) => (api.resolveOptional ? api.resolveOptional(k) : null) } = {}) {
|
|
22
|
+
const inv = pageData.loadInventory();
|
|
23
|
+
if (!inv.ok) return [];
|
|
24
|
+
const lifecycle = resolve('lifecycle');
|
|
25
|
+
if (!lifecycle || typeof lifecycle.listPageTweakTasks !== 'function') return [];
|
|
26
|
+
const tasks = await lifecycle.listPageTweakTasks();
|
|
27
|
+
return pageStatus.stuckPageTweaks({ inventory: inv.data, tasks, now });
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// The stuck list is the same for every builder and only the audience filter is
|
|
31
|
+
// per builder, while GET /me (the hall's busiest boot read) asks on every page
|
|
32
|
+
// load. So the list is read at most once per CACHE_MS across ALL builders and
|
|
33
|
+
// requests (the modules/lifecycle/sky.js precedent): a stuck rule measured in
|
|
34
|
+
// days loses nothing to a minute of staleness, and one in-flight read is shared
|
|
35
|
+
// by concurrent callers rather than repeated.
|
|
36
|
+
const CACHE_MS = Number(process.env.COPY_STUCK_CACHE_MS) || 60_000;
|
|
37
|
+
let cache = { at: 0, p: null };
|
|
38
|
+
function readStuckCached({ nowMs = Date.now(), read = readStuck } = {}) {
|
|
39
|
+
if (!cache.p || nowMs - cache.at >= CACHE_MS) {
|
|
40
|
+
const p = read({ now: nowMs }).catch(() => []);
|
|
41
|
+
cache = { at: nowMs, p };
|
|
42
|
+
}
|
|
43
|
+
return cache.p;
|
|
44
|
+
}
|
|
45
|
+
function _resetCache() { cache = { at: 0, p: null }; }
|
|
46
|
+
|
|
47
|
+
async function readStuckFor(builderId, rank, opts = {}) {
|
|
48
|
+
try {
|
|
49
|
+
return pageStatus.stuckPageTweaksFor({ builderId, rank, stuck: await readStuckCached(opts) });
|
|
50
|
+
} catch (_) {
|
|
51
|
+
return [];
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// The digest's once-a-day rule, pure: post when it is past the digest hour and
|
|
56
|
+
// nothing has been posted on this UTC day. `lastDay` is the day key last posted.
|
|
57
|
+
function digestDue({ nowMs, lastDay, hour = DIGEST_HOUR_UTC }) {
|
|
58
|
+
const d = new Date(nowMs);
|
|
59
|
+
const day = d.toISOString().slice(0, 10);
|
|
60
|
+
return { due: d.getUTCHours() >= hour && lastDay !== day, day };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// One digest attempt. Returns what happened, so the poller and the tests read
|
|
64
|
+
// the same outcome. A page that is not stuck produces nothing; no stuck page,
|
|
65
|
+
// no post.
|
|
66
|
+
async function runDigest({ nowMs = Date.now(), state, post, readStuckImpl = readStuck } = {}) {
|
|
67
|
+
const { due, day } = digestDue({ nowMs, lastDay: state.lastDay });
|
|
68
|
+
if (!due) return { posted: false, reason: 'not_due' };
|
|
69
|
+
const stuck = await readStuckImpl({ now: nowMs });
|
|
70
|
+
state.lastDay = day; // checked once a day, posted or not
|
|
71
|
+
const text = pageStatus.stuckDigestText(stuck);
|
|
72
|
+
if (!text) return { posted: false, reason: 'nothing_stuck' };
|
|
73
|
+
const port = post || (api.resolveOptional ? api.resolveOptional('discord.post', null) : null);
|
|
74
|
+
if (typeof port !== 'function') return { posted: false, reason: 'no_discord' };
|
|
75
|
+
const r = await port({ channel: DIGEST_CHANNEL, text });
|
|
76
|
+
return { posted: !!(r && r.posted), reason: r && r.reason, text };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
module.exports = { DIGEST_CHANNEL, DIGEST_HOUR_UTC, CACHE_MS, readStuck, readStuckCached, readStuckFor, digestDue, runDigest, _resetCache };
|
|
@@ -300,7 +300,12 @@ await test('the module manifest declares no seam another module could write copy
|
|
|
300
300
|
// profile's artist numbers. Anything more provided here would be a seam
|
|
301
301
|
// another module could reach copy through, so the list is pinned exactly and
|
|
302
302
|
// the provider is held to returning no wording below.
|
|
303
|
-
|
|
303
|
+
// task 1004777 adds a second READ: copy-desk.stuckPageTweaksFor, the stuck
|
|
304
|
+
// page tweaks for Home's Needs-attention. It returns page ids, titles, state
|
|
305
|
+
// words and day counts, never a line of copy, and writes nothing.
|
|
306
|
+
assert.deepEqual(manifest.provides, ['copy-desk.artistStats', 'copy-desk.stuckPageTweaksFor'], 'a provided port here would be a copy write path for every other module');
|
|
307
|
+
const stuckSrc = stripComments(read('stuck-tweaks.js'));
|
|
308
|
+
assert.equal(/writeFile|\.query\(|UPDATE |INSERT /.test(stuckSrc), false, 'the stuck-tweaks port writes nothing');
|
|
304
309
|
const stats = read('artist-stats.js');
|
|
305
310
|
assert.equal(/\b(pool|query|writeFile|appendFile|INSERT|UPDATE|DELETE)\b/.test(stats.replace(/\/\/.*$/gm, '')), false,
|
|
306
311
|
'the artist-stats port reads through other ports and writes nothing');
|
|
@@ -263,10 +263,10 @@ await test('in_flight: every open round, its plain state, how long, and stuck wh
|
|
|
263
263
|
['landing:c', 'landing', 'Approved, going live', true],
|
|
264
264
|
['status:h', 'applying', 'Being applied', true],
|
|
265
265
|
['landing:a', 'submitted', 'Submitted, waiting to be applied', true],
|
|
266
|
+
['builders:d', 'waiting_for_artist', 'Waiting for your review', true],
|
|
266
267
|
['landing:b', 'submitted', 'Submitted, waiting to be applied', false],
|
|
267
|
-
['builders:d', 'waiting_for_artist', 'Waiting for your review', false],
|
|
268
268
|
['builders:e', 'writing', 'Being written', false],
|
|
269
|
-
], 'a shipped round is not in flight;
|
|
269
|
+
], 'a shipped round is not in flight; writing is never stuck, and a review left 20 days is (task 1004777)');
|
|
270
270
|
assert.equal(out.in_flight[1].since, ago(17));
|
|
271
271
|
assert.equal(out.in_flight[1].task_id, '3');
|
|
272
272
|
assert.equal(out.pages.find((p) => p.id === 'landing:a').status_label, 'Untweaked', 'the status still counts only what shipped');
|
|
@@ -18,7 +18,9 @@ const asks = require('./asks');
|
|
|
18
18
|
const MAX_READ = 500;
|
|
19
19
|
|
|
20
20
|
// Store (or replace) one matter's ask. `ask` is checkAsk's stored form.
|
|
21
|
-
|
|
21
|
+
// A rewrite closes the matter's reword flag (government_032); a pass to
|
|
22
|
+
// another seat keeps the words, so it passes resolveFlag: false.
|
|
23
|
+
async function saveAsk({ matterId, ask, builderId, resolveFlag = true }) {
|
|
22
24
|
if (!asks.isMatterId(matterId)) throw Object.assign(new Error('bad matter id'), { code: 'BAD_MATTER_ID' });
|
|
23
25
|
const { rows } = await pool.query(
|
|
24
26
|
`INSERT INTO government_asks
|
|
@@ -33,6 +35,7 @@ async function saveAsk({ matterId, ask, builderId }) {
|
|
|
33
35
|
[matterId, ask.question, JSON.stringify(ask.options), ask.recommend.key, ask.recommend.why,
|
|
34
36
|
ask.waits_on, ask.if_nobody_answers, ask.detail || null, builderId == null ? null : builderId],
|
|
35
37
|
);
|
|
38
|
+
if (resolveFlag) await pool.query('UPDATE government_ask_flags SET resolved_at = now() WHERE matter_id = $1 AND resolved_at IS NULL', [matterId]);
|
|
36
39
|
return asks.fromRow(rows[0]);
|
|
37
40
|
}
|
|
38
41
|
|
|
@@ -51,4 +54,37 @@ async function asksFor(matterIds) {
|
|
|
51
54
|
return out;
|
|
52
55
|
}
|
|
53
56
|
|
|
54
|
-
|
|
57
|
+
// ---------- the reword list (government_032, owner 2026-10-23) ----------
|
|
58
|
+
// A reader pressed "?" on a card: this matter's words did not make sense.
|
|
59
|
+
// Flagging again reopens a resolved flag and counts the time.
|
|
60
|
+
async function flagUnclear({ matterId, builderId, note }) {
|
|
61
|
+
if (!asks.isMatterId(matterId)) throw Object.assign(new Error('bad matter id'), { code: 'BAD_MATTER_ID' });
|
|
62
|
+
const text = typeof note === 'string' && note.trim() ? note.trim().slice(0, 500) : null;
|
|
63
|
+
const { rows } = await pool.query(
|
|
64
|
+
`INSERT INTO government_ask_flags (matter_id, flagged_by, note)
|
|
65
|
+
VALUES ($1, $2, $3)
|
|
66
|
+
ON CONFLICT (matter_id) DO UPDATE
|
|
67
|
+
SET flagged_by = EXCLUDED.flagged_by, flagged_at = now(), resolved_at = NULL,
|
|
68
|
+
note = COALESCE(EXCLUDED.note, government_ask_flags.note),
|
|
69
|
+
times = government_ask_flags.times + 1
|
|
70
|
+
RETURNING matter_id, flagged_at, note, times`,
|
|
71
|
+
[matterId, builderId == null ? null : builderId, text],
|
|
72
|
+
);
|
|
73
|
+
return rows[0];
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// The OPEN flags of these matters → Map(matter_id → { flagged_at, note, times }).
|
|
77
|
+
async function unclearFor(matterIds) {
|
|
78
|
+
const ids = [...new Set((Array.isArray(matterIds) ? matterIds : []).filter(asks.isMatterId))].slice(0, MAX_READ);
|
|
79
|
+
const out = new Map();
|
|
80
|
+
if (!ids.length) return out;
|
|
81
|
+
const { rows } = await pool.query(
|
|
82
|
+
`SELECT matter_id, flagged_at, note, times FROM government_ask_flags
|
|
83
|
+
WHERE resolved_at IS NULL AND matter_id = ANY($1::text[])`,
|
|
84
|
+
[ids],
|
|
85
|
+
);
|
|
86
|
+
for (const r of rows) out.set(r.matter_id, { flagged_at: r.flagged_at, note: r.note, times: r.times });
|
|
87
|
+
return out;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
module.exports = { saveAsk, asksFor, flagUnclear, unclearFor, MAX_READ };
|
|
@@ -230,7 +230,7 @@ const PERMISSIONS = [
|
|
|
230
230
|
{ key: 'module.assessment.rerun', system: false, floor: 'metic', guards: 'POST /store/modules/:key/versions/:version/reassess' },
|
|
231
231
|
// Take a module out of the store (task 1003797, ADR 0338 D2). Stops new installs,
|
|
232
232
|
// versions and downloads; never reaches an instance that already holds it.
|
|
233
|
-
{ key: 'module.delist', system: false, floor: 'metic', guards: 'POST /store/modules/:key/delist' },
|
|
233
|
+
{ key: 'module.delist', system: false, floor: 'metic', guards: 'POST /store/modules/:key/delist, POST /store/modules/:key/versions/:version/release (a version the promotion gate held on tester — task 1003804)' },
|
|
234
234
|
{ key: 'security.report.file', system: false, floor: 'metic', guards: 'POST /security/reports, GET /security/adr' },
|
|
235
235
|
{ key: 'llm_cache.use', system: false, floor: 'metic', guards: 'POST /llm-cache/{lookup,store}' },
|
|
236
236
|
{ key: 'backup.manage', system: false, floor: 'metic', guards: 'GET /backup/status, POST /backup/trigger' },
|