@bongos/core 1.21.38 → 1.21.40

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.
@@ -161,10 +161,8 @@ html[data-fullscreen="on"] .street-top { padding-right: 184px; }
161
161
  .street-card__lines { margin: 0; padding: 0; list-style: none; display: flex; flex-direction: column; }
162
162
  .street-card__lines li { padding: 8px 0; border-top: 1px solid var(--street-rule); font-size: 14px; line-height: 1.4; }
163
163
  .street-card__result { margin: 0; font-size: 15px; line-height: 1.45; }
164
- .street-card__open { align-self: flex-start; display: inline-flex; align-items: center; min-height: 24px; padding: 6px 0; font-size: 14px; color: var(--street-accent-text); text-decoration: underline; text-underline-offset: 4px; }
165
- .street-card__open[hidden] { display: none; }
166
164
 
167
- .street-mark:focus-visible, .street-mode:focus-visible, .street-pin:focus-visible, .street-door:focus-visible, .street-sign:focus-visible, .street-card__close:focus-visible, .street-card__open:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }
165
+ .street-mark:focus-visible, .street-mode:focus-visible, .street-pin:focus-visible, .street-door:focus-visible, .street-sign:focus-visible, .street-card__close:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }
168
166
 
169
167
  /* narrower: the strip tightens, then wraps; the readings scroll sideways */
170
168
  @media (max-width: 1180px) {
@@ -195,7 +193,7 @@ html[data-fullscreen="on"] .street-top { padding-right: 184px; }
195
193
  on the hall's tokens. Under 900 px the artboard gives way to a column: the
196
194
  drawing in its own sideways strip, the panel below it. */
197
195
  .inside { position: absolute; inset: 0; display: flex; overflow-x: auto; overflow-y: auto; background: var(--bg); }
198
- .inside[hidden], .inside-annex[hidden], .inside-dhead[hidden], .inside-legend[hidden], .inside-table[hidden], .inside-open[hidden], .inside-note[hidden], .inside-panel[hidden], .inside-more[hidden] { display: none; }
196
+ .inside[hidden], .inside-annex[hidden], .inside-dhead[hidden], .inside-legend[hidden], .inside-open[hidden], .inside-note[hidden], .inside-panel[hidden], .inside-more[hidden] { display: none; }
199
197
  .inside-sizer { position: relative; margin: auto; flex: none; overflow: hidden; }
200
198
  .inside-board { position: absolute; left: 0; top: 0; width: 1440px; height: 708px; transform-origin: 0 0; transform: scale(var(--inside-scale, 1)); color: var(--ink); }
201
199
  .inside-head { position: absolute; left: 32px; top: 16px; display: flex; align-items: center; gap: 16px; z-index: 2; }
@@ -265,10 +263,6 @@ html[data-fullscreen="on"] .street-top { padding-right: 184px; }
265
263
  .inside-meter__fill { height: 6px; width: 0; background: color-mix(in srgb, var(--ink) 30%, var(--bg)); }
266
264
  .inside-meter__fill--full { background: var(--accent); }
267
265
  .inside-meter__line { font-size: 13px; color: var(--ink-soft); line-height: 1.45; }
268
- .inside-table { display: flex; flex-direction: column; gap: 8px; }
269
- .inside-row { display: flex; justify-content: space-between; align-items: center; gap: 12px; width: 100%; background: none; border: 0; border-top: 1px solid var(--street-rule); padding: 10px 0; cursor: pointer; font-family: var(--font-body); font-size: 15px; text-align: left; color: var(--ink); }
270
- .inside-row--needs { color: var(--street-accent-text); }
271
- .inside-row__status { font-size: 12.5px; color: var(--ink-faint); white-space: nowrap; }
272
266
 
273
267
  /* the chosen floor, its sign large; the block re-enters on every pick */
274
268
  .inside-open { display: flex; flex-direction: column; gap: 10px; }
@@ -362,7 +356,7 @@ html[data-fullscreen="on"] .street-top { padding-right: 184px; }
362
356
  .inside-ditem { display: flex; justify-content: space-between; gap: 14px; padding: 8px 0; border-top: 1px solid var(--street-rule); font-size: 14px; line-height: 1.4; }
363
357
  .inside-ditem__meta { color: var(--ink-faint); white-space: nowrap; }
364
358
 
365
- .inside-back:focus-visible, .inside-textbtn:focus-visible, .inside-row:focus-visible, .inside-drawer:focus-visible, .inside-item:focus-visible, .inside-more:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }
359
+ .inside-back:focus-visible, .inside-textbtn:focus-visible, .inside-drawer:focus-visible, .inside-item:focus-visible, .inside-more:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }
366
360
  .inside-floor:focus-visible { outline: 2px solid var(--accent); outline-offset: -3px; }
367
361
  .inside-name:focus { outline: none; }
368
362
  .inside-name:focus-visible { outline: 2px solid var(--accent); outline-offset: 4px; }
@@ -166,10 +166,6 @@
166
166
  <span class="inside-meter__line" id="insideGrowth"></span>
167
167
  </div>
168
168
  </div>
169
- <div class="inside-table" id="insideTableWrap" hidden>
170
- <span class="street-label">on the table here</span>
171
- <div id="insideTable"></div>
172
- </div>
173
169
  <div class="inside-open" id="insideOpenA"></div>
174
170
  <div class="inside-open" id="insideOpenB" hidden></div>
175
171
  <div class="inside-record">
@@ -199,7 +195,6 @@
199
195
  </div>
200
196
  <h2 class="street-card__title" id="streetCardTitle"></h2>
201
197
  <div class="street-card__body" id="streetCardBody"></div>
202
- <a class="street-card__open" id="streetCardOpen" href="/builders/street" hidden>open it where it lives</a>
203
198
  </div>
204
199
 
205
200
  <div class="street-toast" id="streetToast" role="status" aria-live="polite" hidden></div>
@@ -78,7 +78,9 @@
78
78
  var $ = function (id) { return document.getElementById(id); };
79
79
  var media = window.matchMedia ? window.matchMedia('(prefers-color-scheme: dark)') : null;
80
80
  var data = { docket: null, goals: [], rollups: [], earnings: null, loaded: false, tasks: {}, room: null, constitution: null };
81
- var card = { cv: null, form: null, error: '', busy: false, fresh: null, answer: null };
81
+ // ask: the act whose chips stand open; more: the detail is open in place;
82
+ // pick: which of the review queue the queue's card shows (task 1004566)
83
+ var card = { cv: null, form: null, ask: null, more: false, pick: null, error: '', busy: false, fresh: null, answer: null };
82
84
  var view = { open: null, opener: null, model: null, scale: 1, mode: null, inside: null, insideModel: null };
83
85
  // the desk's own state: rows you acted on this visit, what you did per floor,
84
86
  // the floors you cleared, the row whose facts or chips are open
@@ -493,19 +495,6 @@
493
495
  var fill = $('insideFill');
494
496
  if (fill) { fill.style.width = bv.fill + '%'; fill.className = 'inside-meter__fill' + (bv.fill >= 80 ? ' inside-meter__fill--full' : ''); }
495
497
  setText('insideGrowth', bv.growth);
496
- show('insideTableWrap', bv.hasTable);
497
- var table = $('insideTable'); clear(table);
498
- bv.table.forEach(function (r) {
499
- var b = node('button', 'inside-row' + (r.state === 'needs' ? ' inside-row--needs' : ''));
500
- b.setAttribute('type', 'button');
501
- b.setAttribute('data-matter', r.matter);
502
- b.setAttribute('aria-controls', 'streetCard');
503
- b.setAttribute('aria-expanded', view.open === r.matter ? 'true' : 'false');
504
- b.appendChild(node('span', 'inside-row__label', r.label));
505
- b.appendChild(node('span', 'inside-row__status', r.status));
506
- b.addEventListener('click', function () { openCard(r.matter, b); });
507
- if (table) table.appendChild(b);
508
- });
509
498
  // the chosen floor: two blocks that take turns, so the block re-enters on every pick
510
499
  var on = $(bv.flipA ? 'insideOpenA' : 'insideOpenB'), off = $(bv.flipA ? 'insideOpenB' : 'insideOpenA');
511
500
  if (off) { off.hidden = true; clear(off); }
@@ -1054,21 +1043,42 @@
1054
1043
 
1055
1044
 
1056
1045
  // ---------- the matter card (task 1004528: it acts) ----------
1046
+ // Its acts are the desk row's for the same item (GovDesk.cardFor, task
1047
+ // 1004566): the same words, chips, routes and permission checks. "more" grows
1048
+ // the card in place; nothing on it opens an older page to decide.
1057
1049
  function fillCard() {
1058
- var C = window.GovMatterCard;
1059
- var cv = C ? C.model({ docket: data.docket, key: view.open, room: data.room, constitution: data.constitution, now: Date.now() }) : null;
1050
+ var C = window.GovMatterCard, D = window.GovDesk;
1051
+ var ctx = { room: data.room, constitution: data.constitution, now: Date.now(), goals: (data.goals || []).filter(function (g) { return g && g.status === 'open'; }), pick: card.pick };
1052
+ var cv = D && D.cardFor ? D.cardFor(view.open, data.docket, ctx) : C ? C.model({ docket: data.docket, key: view.open, room: data.room, constitution: data.constitution, now: Date.now() }) : null;
1060
1053
  if (!cv) { closeCard(false); return null; }
1061
1054
  card.cv = cv;
1062
1055
  $('streetCardWhere').textContent = cv.where;
1063
1056
  $('streetCardWhen').textContent = cv.when;
1064
1057
  $('streetCardTitle').textContent = cv.title;
1065
- C.render(document, $('streetCardBody'), cv, { act: actOn, openForm: openForm }, { form: card.form, error: card.error, busy: card.busy, fresh: card.fresh });
1066
- var open = $('streetCardOpen');
1067
- if (cv.href) open.setAttribute('href', cv.href);
1068
- open.hidden = !cv.href;
1058
+ C.render(document, $('streetCardBody'), cv, { act: actOn, openForm: openForm, ask: askFor, more: toggleMore, pick: pickQueued }, { form: card.form, ask: card.ask, more: card.more, error: card.error, busy: card.busy, fresh: card.fresh });
1059
+ var frame = $('streetCard'); if (frame) frame.setAttribute('data-more', card.more ? 'open' : 'closed');
1069
1060
  placeCard();
1070
1061
  return cv;
1071
1062
  }
1063
+ function askFor(key) {
1064
+ card.ask = card.ask === key ? null : key;
1065
+ card.error = '';
1066
+ fillCard();
1067
+ var body = $('streetCardBody'), first = body && body.querySelector ? body.querySelector('.mcard-chip, .mcard-chips__field') : null;
1068
+ if (card.ask && first && first.focus) first.focus();
1069
+ }
1070
+ function toggleMore() {
1071
+ card.more = !card.more;
1072
+ fillCard();
1073
+ var mb = $('streetCardBody'), t = mb && mb.querySelector ? mb.querySelector('[data-act="more"]') : null;
1074
+ if (t && t.focus) t.focus();
1075
+ }
1076
+ function pickQueued(key) {
1077
+ card.pick = key; card.ask = null; card.more = false; card.form = null; card.error = '';
1078
+ fillCard();
1079
+ var close = $('streetCardClose');
1080
+ if (close && close.focus) close.focus();
1081
+ }
1072
1082
  function placeCard() {
1073
1083
  var card = $('streetCard');
1074
1084
  if (!card || !view.open || !view.model) return;
@@ -1084,7 +1094,7 @@
1084
1094
  function openCard(key, opener) {
1085
1095
  view.open = key;
1086
1096
  view.opener = opener || null;
1087
- card.form = null; card.error = ''; card.fresh = null;
1097
+ card.form = null; card.ask = null; card.more = false; card.pick = null; card.error = ''; card.fresh = null;
1088
1098
  var el = $('streetCard');
1089
1099
  if (!fillCard()) return;
1090
1100
  el.hidden = false;
@@ -1097,19 +1107,17 @@
1097
1107
  var was = view.opener;
1098
1108
  view.open = null;
1099
1109
  view.opener = null;
1100
- card.cv = null; card.form = null; card.error = ''; card.busy = false; card.fresh = null;
1110
+ card.cv = null; card.form = null; card.ask = null; card.more = false; card.pick = null; card.error = ''; card.busy = false; card.fresh = null;
1101
1111
  var el = $('streetCard');
1102
1112
  if (el) el.hidden = true;
1103
1113
  draw();
1104
1114
  // The pins were redrawn, so focus goes back to the new button for the same matter.
1105
1115
  if (restore !== false && was && was.getAttribute) {
1106
1116
  var key = was.getAttribute('data-matter');
1107
- // the pins and the table rows were redrawn: focus the new control for the same matter
1117
+ // the pins were redrawn: focus the new pin for the same matter
1108
1118
  // .children is an HTMLCollection, which concat does not spread: copy it first
1109
1119
  var list = function (id) { var el = $(id); return el && el.children ? Array.prototype.slice.call(el.children) : []; };
1110
- var pins = list('streetPins');
1111
- var rows = list('insideTable');
1112
- var pool = view.inside ? [].concat(rows, pins) : [].concat(pins, rows);
1120
+ var pool = list('streetPins');
1113
1121
  for (var i = 0; i < pool.length; i++) {
1114
1122
  if (pool[i].getAttribute('data-matter') === key) { if (pool[i].focus) pool[i].focus(); break; }
1115
1123
  }
@@ -1138,32 +1146,36 @@
1138
1146
  card.form = name || null;
1139
1147
  card.error = '';
1140
1148
  fillCard();
1141
- var f = name === 'object' ? $('mcardReason') : name === 'revise' ? $('mcardRevise') : $('streetCardClose');
1149
+ var f = name === 'revise' ? $('mcardRevise') : $('streetCardClose');
1142
1150
  if (f && f.focus) f.focus();
1143
1151
  }
1144
1152
  // One act: checked, sent to its own route (the server's permission checks
1145
1153
  // decide), then answered. An act that records nothing (abstain, hold) is
1146
1154
  // answered without a request.
1155
+ // An act from the card goes with the desk row's own card when a row stands
1156
+ // behind it (its subject: which route, the council's), else the model's.
1147
1157
  function actOn(key, input) {
1148
1158
  var C = window.GovMatterCard, cv = card.cv;
1149
1159
  if (!C || !cv || card.busy) return Promise.resolve(false);
1150
- var refused = C.check(cv, key, input);
1160
+ var subj = cv.row && cv.row.card ? cv.row.card : cv;
1161
+ var refused = C.check(subj, key, input);
1151
1162
  if (refused) { card.error = refused; fillCard(); return Promise.resolve(false); }
1152
1163
  card.error = '';
1153
- var req = C.request(cv, key, input);
1154
- if (!req) return Promise.resolve(answered(cv, key, {}, false));
1164
+ var req = C.request(subj, key, input);
1165
+ if (!req) return Promise.resolve(answered(subj, key, {}, false));
1155
1166
  card.busy = true;
1156
1167
  fillCard();
1157
1168
  return send(req).then(function (out) {
1158
1169
  card.busy = false;
1159
1170
  if (!out.ok) { card.error = C.refusal(out.status, out.body); if (view.open) fillCard(); return false; }
1160
- return answered(cv, key, out.body, true);
1171
+ return answered(subj, key, out.body, true);
1161
1172
  });
1162
1173
  }
1163
1174
  function answered(cv, key, body, recorded) {
1164
1175
  var a = window.GovMatterCard.answer(cv, key, body);
1165
1176
  say(a.toast);
1166
- card.form = null;
1177
+ card.form = null; card.ask = null;
1178
+ card.pick = null;
1167
1179
  card.fresh = key === 'for' || key === 'object' ? 'you' : null;
1168
1180
  if (a.building && a.kind) card.answer = { building: a.building, matter: cv.key, kind: a.kind, at: Date.now() };
1169
1181
  if (a.close) closeCard(true);
@@ -21,12 +21,12 @@ Built 1:1 on `buildingView()` and `districtView()` of the same prototype. The in
21
21
  Files: `street-inside.js` (`window.GovInside`, node-require-safe, pure): the prototype's `crown()`, `workItem()`, `floorSymbol()`, `symbolPath()` (all forty symbols) and the section drawing itself, verbatim, plus the real-data adapters. `street.js` builds the nodes; `street.css` carries the interior's styles under the `inside-` prefix.
22
22
 
23
23
  - **The building in section.** Every storey a real floor (slab, cut walls with glazing, three lamps, a partition with a doorway, the framed sign with the floor's symbol, its name and state lettered on the wall, its count, its level number), dark empty floors up to the building's capacity, the crown per building, the lift in its shaft, the orange frame and orange sign on the chosen floor, and the annex lot once the building is full. An unheld Review is the dashed ghost (`GovStreet.reviewHeldOf()`, so the street and the interior agree).
24
- - **The right panel.** Name and what it holds, who holds it (seat chips), how it decides (the rule glyph), **change how this works**, the fill meter with its growth line, on the table here, the chosen floor with its sign large, and the record as monthly filing drawers.
24
+ - **The right panel.** Name and what it holds, who holds it (seat chips), how it decides (the rule glyph), **change how this works**, the fill meter with its growth line, the chosen floor with its sign large, and the record as monthly filing drawers.
25
25
  - **Floors are the prototype's structure; a number appears only when the docket holds it.** `GovInside.PLAN` keeps each building's floors, order and capacity. A floor bound to a docket source shows that source's open matters at this building (count, line, needs you, the lines); one bound to a reading shows its figure; Planning's Goals floor counts the open goals. Every other floor says "not recorded yet" on the wall, lettered faint, with no number. A source that is off, coming, not yours or unreadable says which, never a figure. Filling every floor with real data is task 1004527, through `floorFor()` and `PLAN`.
26
26
  - **Who holds it.** The docket names no holders yet (task 1004529), so the seat is an empty dashed chip and says "who holds it is not set on this project yet". The rule is the one the building's open matters are decided by, else the design's default, which the words call "by default". `holderOf()` reads a docket building's `holders` / `rule` / `who` first, so 1004529 lands without touching the drawing.
27
27
  - **The lift and the flip.** The lift is one node kept across draws, so picking a floor slides it by a CSS transform transition; the chosen floor's block is two blocks that take turns (`flipA` / `flipB`), so it re-enters on every pick. Both are dead under reduced motion.
28
28
  - **Change how this works** is present and honest: pressed, it says drafting an amendment from here is not built yet (task 1004530 wires it).
29
- - **On the table here** rows open the same short card a pin opens, through `openCard()`; Escape gives focus back to the row.
29
+ - **No "on the table here" list.** The prototype's list of the building's open matters was removed on the owner's direction (task 1004565): the floor desks carry every matter as an actionable row, so the list was duplication. Inside a building a matter is its desk row; the short card opens from the street's pins.
30
30
  - **Districts.** The goal's tasks (`GET /tasks?goal_id=N`, read once per goal): shape from the discipline (tower engineer, studio artist, glass front UI, pavilion ideator; none is a tower), stage from the status (backlog and blocked a staked plot, ready a slab, active a scaffold, completed and confirmed built with a flag, shipped lit), label the kind; height from the estimate. Up to ten stand on the road, work in flight first; the rest are counted with a link to the goal. Each one opens its task.
31
31
  - **Narrow.** Under 900 px the board becomes a column: the drawing in its own sideways strip (never scaled under 0.6, so a floor stays a 24 px target), then the panel.
32
32
 
@@ -42,6 +42,8 @@ function blockerDocketMatter(b, mayResolve) {
42
42
  tally: null,
43
43
  href: `/blockers#blocker-${b.id}`,
44
44
  ref: { type: 'blocker', id: b.id },
45
+ // the card's "more" (task 1004566): the body GET /blockers shows everyone
46
+ detail: b.body_md || null,
45
47
  // the floor desk's one act (task 1004560): decide, by one of its options
46
48
  ...(mayResolve ? { acts: [{ key: 'decide', options: blockerOptions(b.body_md) }] } : {}),
47
49
  };
@@ -41,6 +41,8 @@ function overrideMatter(r, mayDecide) {
41
41
  tally: null,
42
42
  href: `/task/${encodeURIComponent(r.task_id)}`,
43
43
  ref: { type: 'override_request', id: r.id },
44
+ // the card's "more" (task 1004566): the reason GET /override-requests shows
45
+ detail: r.rationale || null,
44
46
  };
45
47
  }
46
48
 
@@ -58,6 +60,8 @@ function removalMatter(r) {
58
60
  tally: null,
59
61
  href: `/task/${encodeURIComponent(r.proposal_task_id)}`,
60
62
  ref: { type: 'task', id: r.proposal_task_id },
63
+ // the card's "more" (task 1004566): the proposal's own text, from the same read
64
+ detail: r.description || null,
61
65
  // POST /removal-proposals/:id/decide, the docket source's own atom (task 1004559)
62
66
  acts: [{ key: 'remove' }, { key: 'keep' }],
63
67
  };
@@ -236,6 +236,8 @@ function accessRequestDocketMatter(r) {
236
236
  tally: null,
237
237
  href: '/watch',
238
238
  ref: { type: 'access_request', id: r.id },
239
+ // the card's "more" (task 1004566): their note, as GET /access-requests shows it
240
+ detail: r.note || null,
239
241
  };
240
242
  }
241
243
 
@@ -56,6 +56,9 @@ function redteamDocketMatter(r) {
56
56
  tally: null,
57
57
  href: '/watch',
58
58
  ref: { type: 'vulnerability_report', id: r.id },
59
+ // the card's "more" (task 1004566): what GET /security/reports shows this
60
+ // same atom — what it targets and the report's description
61
+ detail: [r.target ? `Target: ${r.target}` : '', r.description || ''].filter(Boolean).join('\n\n') || null,
59
62
  // The floor desk's acts (task 1004559): the docket only lists this for a
60
63
  // holder of the adjudicate atom, which both routes check.
61
64
  acts: [{ key: 'confirm' }, { key: 'dispute' }],
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.21.38",
3
+ "version": "1.21.40",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.21.38",
9
+ "version": "1.21.40",
10
10
  "license": "AGPL-3.0-or-later",
11
11
  "dependencies": {
12
12
  "express": "^4.21.2",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.21.38",
3
+ "version": "1.21.40",
4
4
  "description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "main": "src/platform-server.js",
@@ -8992,5 +8992,17 @@
8992
8992
  "id": "1004564",
8993
8993
  "text": "If you put something up for a vote and nobody has voted yet, you can now withdraw it with one tap on your desk. Once anyone votes, it stays with the vote. A small database change makes \"withdrawn\" a valid way for a sitting to"
8994
8994
  }
8995
+ ],
8996
+ "1.21.39": [
8997
+ {
8998
+ "id": "1004565",
8999
+ "text": "Inside each building on the governor street, the separate \"on the table here\" list is gone. The floor desks already show every matter as a row you can act on, so the list only repeated them. The rest of the building panel look"
9000
+ }
9001
+ ],
9002
+ "1.21.40": [
9003
+ {
9004
+ "id": "1004566",
9005
+ "text": "Every card on the governor street now acts where it opens. Pressing a pin gives the same buttons as that item's row at its building desk: confirm or dispute a security report, remove or keep, decide a blocker by tapping an opt"
9006
+ }
8995
9007
  ]
8996
9008
  }
@@ -53,6 +53,13 @@ async function listModuleRechecks({ limit = RECHECK_LIMIT, db = null } = {}) {
53
53
 
54
54
  // One version as a docket matter. Pure. A failed gate needs a governor who may
55
55
  // ask for a re-check; a pending part is a re-check already moving.
56
+ // The one-tap reasons a re-check may give (POST /store/modules/:key/versions/
57
+ // :version/reassess requires one; task 1004566).
58
+ const RECHECK_REASONS = Object.freeze([
59
+ { key: 'fixed', label: 'fixed since' },
60
+ { key: 'misfired', label: 'check misfired' },
61
+ { key: 'again', label: 'look again' },
62
+ ]);
56
63
  function moduleRecheckMatter(r, mayRerun) {
57
64
  const failed = r.security_failed === true;
58
65
  const pending = Array.isArray(r.pending_parts) ? r.pending_parts.filter(Boolean) : [];
@@ -70,7 +77,11 @@ function moduleRecheckMatter(r, mayRerun) {
70
77
  proposer: null,
71
78
  tally: null,
72
79
  href: '/modules',
73
- ref: { type: 'module_version', id: r.version_id },
80
+ // "<key>@<version>": the reassess route's two path parts (task 1004566)
81
+ ref: { type: 'module_version', id: `${r.module_key}@${r.version}` },
82
+ // the one act a governor takes on it, under the reassess route's own atom,
83
+ // only when its security check failed (a pending check is still running)
84
+ ...(failed && mayRerun ? { acts: [{ key: 'recheck', options: RECHECK_REASONS }] } : {}),
74
85
  };
75
86
  }
76
87
 
package/src/module-api.js CHANGED
@@ -75,7 +75,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
75
75
  // MAJOR (see allowBoxScope below): passes the request through untouched.
76
76
  function deprecatedNoopMiddleware(_req, _res, next) { next(); }
77
77
 
78
- const CORE_VERSION = '1.21.38'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
78
+ const CORE_VERSION = '1.21.40'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
79
79
 
80
80
  // A namespaced logger so a module's log lines are attributable + consistent.
81
81
  // Usage: const log = api.logger('discord'); log.info('mounted');
@@ -101,6 +101,26 @@ test('normalizeMatter: the one shape, prefixed id, ISO dates', () => {
101
101
  assert.deepEqual(Object.keys(m).sort(), ['building', 'deadline', 'href', 'id', 'opened_at', 'proposer', 'ref', 'rule', 'short', 'source', 'state', 'tally', 'title', 'where'].sort());
102
102
  });
103
103
 
104
+ test('normalizeMatter carries a matter\'s detail for the card\'s "more" only as bounded text (task 1004566)', () => {
105
+ const base = { id: 1, title: 't' };
106
+ const at = { source: 'blockers', building: 'planning' };
107
+ assert.equal(docket.normalizeMatter({ ...base, detail: ' Option A: Render\n\nOption B: Fly ' }, at).detail, 'Option A: Render\n\nOption B: Fly', 'lines kept, ends trimmed');
108
+ assert.equal(docket.normalizeMatter({ ...base, detail: 'x'.repeat(5000) }, at).detail.length, 4000);
109
+ for (const bad of [null, '', ' ', 42, { md: 'x' }, ['x']]) assert.ok(!('detail' in docket.normalizeMatter({ ...base, detail: bad }, at)), JSON.stringify(bad));
110
+ });
111
+
112
+ test('the sources hand the detail their own list route shows, and a failed module check its one act (task 1004566)', () => {
113
+ const r = reportsRoute.redteamDocketMatter({ id: 2, title: 'x', status: 'open', target: '/login', description: 'No limit on tries.' });
114
+ assert.equal(r.detail, 'Target: /login\n\nNo limit on tries.');
115
+ assert.equal(reportsRoute.redteamDocketMatter({ id: 2, title: 'x', status: 'open' }).detail, null);
116
+ const failed = moduleRechecks.moduleRecheckMatter({ version_id: 7, module_key: 'image-tools', version: '1.2.0', security_failed: true }, true);
117
+ assert.deepEqual(failed.ref, { type: 'module_version', id: 'image-tools@1.2.0' }, 'the reassess route\'s two path parts');
118
+ assert.deepEqual(failed.acts.map((a) => a.key), ['recheck']);
119
+ assert.ok(failed.acts[0].options.length >= 2 && failed.acts[0].options.every((o) => o.label.length <= 16), 'reasons are one-tap chips');
120
+ assert.ok(!('acts' in moduleRechecks.moduleRecheckMatter({ version_id: 7, module_key: 'm', version: '1.0.0', security_failed: true }, false)), 'not without the reassess atom');
121
+ assert.ok(!('acts' in moduleRechecks.moduleRecheckMatter({ version_id: 8, module_key: 'm', version: '1.0.0', security_failed: false, pending_parts: ['tests'] }, true)), 'not while its check is still running');
122
+ });
123
+
104
124
  test('normalizeMatter fails SAFE: unknown state → moving (never a false "needs you"), unknown rule → null', () => {
105
125
  const m = docket.normalizeMatter({ id: 1, title: 't', state: 'urgent!!', rule: 'vibes', building: 'castle' }, { source: 's', building: 'review' });
106
126
  assert.equal(m.state, 'moving');