instar 1.3.1641 → 1.3.1642

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.
@@ -24,6 +24,7 @@ export const WATCHER_CADENCE_MS = 600000; // GreenPrAutoMerger
24
24
  export const VERIFY_MINUTES = 45; // a full local verify takes about this long
25
25
  export const ARMED_OVERDUE_MS = 24 * 3600000; // the watcher's own armed-confirm ceiling
26
26
  export const STALL_TICKS = 6; // readiness ticks in a row with no usable answer (about 3 h)
27
+ export const DEV_FOLDS = ['shipping', 'feedback', 'pace'];
27
28
  export const PACE_WINDOWS = [7, 14, 30];
28
29
  export const PACE_PEOPLE = [{ key: 'team', label: 'Everyone' }, { key: 'codey', label: 'Codey' }, { key: 'echo', label: 'Echo' }, { key: 'other', label: 'Others' }];
29
30
  export const PACE_METRICS = [
@@ -183,12 +184,34 @@ export function buildShipping({ watcher, verify, release } = {}, now = Date.now(
183
184
  }
184
185
  const armedPrs = w && Array.isArray(w.armed) ? w.armed.map((a) => a.pr) : [];
185
186
  return {
186
- state: 'ok', headline, tone, tiles, rows, watcher: wl, release: releaseLine(release, now),
187
+ state: 'ok', headline, tone: wl && wl.stopped && tone !== 'bad' ? 'attn' : tone, tiles, rows, moving, watcher: wl, release: releaseLine(release, now),
187
188
  partial: !w || !v, armedPrs, stopped: Boolean(wl && wl.stopped), rollbackLatches: (w && w.gate && Array.isArray(w.gate.rollback)) ? w.gate.rollback : [],
188
- line: !w ? 'Shipping: can’t tell' : `Shipping: ${moving === 0 ? 'nothing on the way' : `${moving} on the way`}${needs ? `, ${needs} need${needs === 1 ? 's' : ''} you` : ''}${stuck ? `, ${needs ? '' : ''}${stuck} stuck` : ''}`,
189
+ line: !w ? 'Shipping: can’t tell' : `Shipping: ${wl && wl.stopped ? 'auto-merge stopped, ' : ''}${moving === 0 ? 'nothing on the way' : `${moving} on the way`}${needs ? `, ${needs} need${needs === 1 ? 's' : ''} you` : ''}${stuck ? `, ${needs ? '' : ''}${stuck} stuck` : ''}`,
189
190
  repoKnown: Boolean(repo),
190
191
  };
191
192
  }
193
+ /**
194
+ * The lead card while auto-merging is stopped, or null when it is not. It is the first
195
+ * thing on the page because the stop is invisible otherwise: nothing merges, and the
196
+ * Shipping fold that used to be its only sign is often closed.
197
+ */
198
+ export function stoppedLead(shipping) {
199
+ if (!shipping || !shipping.stopped) return null;
200
+ const n = shipping.moving || 0;
201
+ return {
202
+ title: 'Automatic merging is stopped',
203
+ detail: `No pull request merges on its own until it is back on.${n ? ` ${plural(n, 'pull request is', 'pull requests are')} on the way.` : ''}`,
204
+ why: shipping.watcher.text,
205
+ button: 'Turn auto-merge back on',
206
+ };
207
+ }
208
+
209
+ /** The section a `?s=` link names, from the location hash; null for none or an unknown one. */
210
+ export function foldFromHash(hash) {
211
+ const s = new URLSearchParams(String(hash || '').split('?')[1] || '').get('s');
212
+ return DEV_FOLDS.includes(s) ? s : null;
213
+ }
214
+
192
215
  function ordinal(n) { return n === 1 ? '1st' : n === 2 ? '2nd' : n === 3 ? '3rd' : `${n}th`; }
193
216
 
194
217
  /** Words for the hold result (never a blanket "held": the server says whether an armed merge was disarmed). */
@@ -545,11 +568,14 @@ export function commitResultWords(status, data) {
545
568
  // ── The combined page ────────────────────────────────────────────────────────
546
569
  /** The page headline: the worst state first, coverage stated. */
547
570
  export function developmentHeadline({ shipping, feedback, pace } = {}) {
571
+ const stopped = Boolean(shipping && shipping.stopped);
572
+ const needs = shipping && shipping.rows ? shipping.rows.filter((r) => r.group === 'needs') : [];
548
573
  if (feedback && feedback.todos && feedback.todos.length) {
549
- const n = feedback.todos.length + (shipping && shipping.rows ? shipping.rows.filter((r) => r.group === 'needs').length : 0);
550
- return { text: n === 1 ? 'One thing needs you.' : `${n} things need you.`, tone: 'attn', sub: feedback.todos[0].sentence };
574
+ const n = feedback.todos.length + needs.length + (stopped ? 1 : 0);
575
+ return { text: n === 1 ? 'One thing needs you.' : `${n} things need you.`, tone: 'attn', sub: stopped ? 'Automatic merging is stopped.' : feedback.todos[0].sentence };
551
576
  }
552
- const needs = shipping && shipping.rows ? shipping.rows.filter((r) => r.group === 'needs') : [];
577
+ // Stopped is never "Nothing here needs you": it comes before every calmer reading below.
578
+ if (stopped) return { text: needs.length ? `${needs.length + 1} things need you.` : 'Automatic merging is stopped.', tone: 'attn', sub: needs.length ? 'Automatic merging is stopped.' : shipping.line };
553
579
  if (needs.length) return { text: needs.length === 1 ? 'One pull request needs your approval on GitHub.' : `${needs.length} pull requests need your approval on GitHub.`, tone: 'attn', sub: shipping.line };
554
580
  if (feedback && feedback.state === 'cant-tell') return { text: 'Can’t tell whether anything needs you.', tone: 'muted', sub: feedback.headline };
555
581
  const stuck = shipping && shipping.rows ? shipping.rows.filter((r) => r.group === 'stuck').length : 0;
@@ -10,7 +10,7 @@
10
10
  // server requires it. Every value reaches the DOM through textContent.
11
11
 
12
12
  import {
13
- buildShipping, buildPace, buildFeedback, developmentHeadline, maintainerMode, authorityCard, holdWords, verifyWords, rollbackWords,
13
+ buildShipping, buildPace, buildFeedback, developmentHeadline, stoppedLead, foldFromHash, maintainerMode, authorityCard, holdWords, verifyWords, rollbackWords,
14
14
  readinessAuthorityRequest, triageAuthorityRequest, commitRequest, authorityResultWords, commitResultWords, newDecisionRef, PACE_METRICS, DEV_TOOLS_KEY,
15
15
  } from './development-model.js';
16
16
  import { el, setKids, pageHead, section, sheet, closeSheet, story, details, note, cantTell, askPin } from './ui.js';
@@ -19,8 +19,8 @@ import { legacyHref } from './nav-model.js';
19
19
  const EYEBROW = 'Work · Development';
20
20
  const TIMEOUT_MS = 5000;
21
21
  const PROBE_FRESH_MS = 5 * 60000;
22
- // The open fold, the Pace controls and any limits the operator typed, kept across refreshes (F9: a poll never rebuilds typed input).
23
- const view = { open: null, days: 14, person: 'team', metric: 'merges', day: null, envelope: { readiness: null, triage: null } };
22
+ // The open fold, a link's section still to scroll to, the Pace controls and any limits the operator typed, kept across refreshes (F9: a poll never rebuilds typed input).
23
+ const view = { open: null, scrollTo: null, days: 14, person: 'team', metric: 'merges', day: null, envelope: { readiness: null, triage: null } };
24
24
  let last = null;
25
25
  let probe = null; // { at, promise } — the maintainer-mode reads, once per page load
26
26
 
@@ -90,9 +90,13 @@ export function groupExtras(g, { get }) {
90
90
 
91
91
  // ── the page ─────────────────────────────────────────────────────────────────
92
92
  export async function renderDevelopment(main, ctx) {
93
- if (!main.querySelector('.dev-page')) setKids(main, el('section', { class: 'hero page-hero' }, el('div', { class: 'eyebrow', text: EYEBROW }), el('h1', { class: 'page-title skeleton', text: 'Loading development' })));
94
- const want = new URLSearchParams((location.hash.split('?')[1] || '')).get('s');
95
- if (want && !view.open) view.open = want;
93
+ // Each navigation gets a new <main>, so no page yet means someone just arrived: a `?s=` link wins over the
94
+ // remembered fold then. A 30-second refresh keeps the page, so it never overrides what the operator opened or closed.
95
+ if (!main.querySelector('.dev-page')) {
96
+ setKids(main, el('section', { class: 'hero page-hero' }, el('div', { class: 'eyebrow', text: EYEBROW }), el('h1', { class: 'page-title skeleton', text: 'Loading development' })));
97
+ const want = foldFromHash(location.hash);
98
+ if (want) { view.open = want; view.scrollTo = want; }
99
+ }
96
100
  const [watcher, verify, release, readiness, triageSummary, triageProposal, execute, queue, drain, pace] = await Promise.all([
97
101
  read('/green-pr-automerge'), read('/local-verify'), read('/release-readiness'),
98
102
  read('/feedback-factory/readiness-authorities/proposal'), read('/feedback-factory/triage/summary'), read('/feedback-factory/triage/authority/proposal'),
@@ -117,13 +121,14 @@ function paint() {
117
121
  const todos = feedback.todos;
118
122
  const first = todos[0] || null;
119
123
  const lead = first ? todoCard(first, deps, true) : null;
124
+ const stopped = stoppedCard(shipping, deps);
120
125
  const moreLine = todos.length > 1 ? el('button', { class: 'link-btn older-line', type: 'button', text: `${todos.length - 1} more ${todos.length === 2 ? 'thing needs' : 'things need'} you ›`, onclick: () => openMore(todos.slice(1), deps) }) : null;
121
126
 
122
127
  // First arrival opens the section with the worst news (Shipping otherwise); 'none' is the operator's own collapse.
123
128
  if (!view.open) view.open = feedback.tone === 'attn' || feedback.state === 'cant-tell' ? 'feedback' : 'shipping';
124
129
  setKids(main, el('div', { class: 'dev-page' },
125
130
  pageHead({ eyebrow: EYEBROW, headline: h.text, sub: h.sub, tone: h.tone }),
126
- lead, moreLine,
131
+ stopped, lead, moreLine,
127
132
  el('div', { class: 'page-sections' },
128
133
  fold({ id: 'shipping', title: 'Shipping', line: shipping.line, tone: shipping.tone, body: () => shippingBody(shipping, deps) }),
129
134
  fold({ id: 'feedback', title: 'Feedback', line: feedback.line, tone: feedback.tone, body: () => feedbackBody(feedback, deps) }),
@@ -131,8 +136,12 @@ function paint() {
131
136
  ),
132
137
  el('p', { class: 'foot-note' }, el('a', { href: legacyHref('pr-pipeline'), text: 'Legacy PR pipeline' }), document.createTextNode(' · '), el('a', { href: legacyHref('feedback-drain'), text: 'feedback drain' }), document.createTextNode(' · '), el('a', { href: legacyHref('throughput'), text: 'throughput' })),
133
138
  ));
134
- const sec = main.querySelector(`#sec-${view.open}`);
135
- if (sec && new URLSearchParams((location.hash.split('?')[1] || '')).get('s') === view.open) sec.scrollIntoView({ block: 'start' });
139
+ // Once per arrival: a refresh or a fold tap must not pull the page back to the linked section.
140
+ if (view.scrollTo) {
141
+ const sec = main.querySelector(`#sec-${view.scrollTo}`);
142
+ view.scrollTo = null;
143
+ if (sec) sec.scrollIntoView({ block: 'start' });
144
+ }
136
145
  }
137
146
 
138
147
  /** One of the three sections: a one-line summary that opens in place; one open at a time. */
@@ -159,6 +168,17 @@ function toneLine(l, extra) {
159
168
  function block(title, ...kids) { return el('section', { class: 'sheet-block' }, el('h3', { class: 'eyebrow', text: title }), ...kids); }
160
169
  function sub(title, ...kids) { return el('div', { class: 'dev-sub' }, el('h3', { class: 'eyebrow sub-label', text: title }), ...kids); }
161
170
 
171
+ /** The one way back on: the lead card and the Shipping fold both use it. */
172
+ function backOnButton(s, deps, primary = false) {
173
+ return el('button', { class: primary ? 'btn-primary btn-inline' : 'btn-quiet', type: 'button', text: 'Turn auto-merge back on', onclick: () => enableAutoMerge(s, deps) });
174
+ }
175
+ /** Auto-merging is stopped: the first thing on the page, whichever fold is open. */
176
+ function stoppedCard(s, deps) {
177
+ const c = stoppedLead(s);
178
+ if (!c) return null;
179
+ return el('div', { class: 'need-card stopped-lead' }, el('span', { class: 'need-title', text: c.title }), el('span', { class: 'need-sub', text: c.detail }), el('span', { class: 'need-sub', text: c.why }), backOnButton(s, deps, true));
180
+ }
181
+
162
182
  // ── to-dos (Feedback approvals and links) ────────────────────────────────────
163
183
  function todoCard(todo, deps, lead = false) {
164
184
  if (todo.kind === 'link') {
@@ -263,11 +283,8 @@ function shippingBody(s, deps) {
263
283
  const rows = s.rows.map((r) => ({ ...r, sub: r.sub, end: r.end }));
264
284
  const list = section({ id: 'prs', title: 'Pull requests', rows, empty: s.headline, onRow: (r) => openPr(r, s, deps) });
265
285
  const foot = el('div', { class: 'sheet-actions dev-foot' });
266
- if (s.stopped) {
267
- const back = el('button', { class: 'btn-quiet', type: 'button', text: 'Turn auto-merge back on' });
268
- back.addEventListener('click', () => enableAutoMerge(s, deps));
269
- foot.append(back);
270
- } else {
286
+ if (s.stopped) foot.append(backOnButton(s, deps));
287
+ else {
271
288
  const stop = el('button', { class: 'btn-quiet btn-danger-outline', type: 'button', text: 'Stop auto-merging' });
272
289
  stop.addEventListener('click', () => stopAutoMerge(s, deps));
273
290
  foot.append(stop);
@@ -279,7 +296,8 @@ function shippingBody(s, deps) {
279
296
  release, toneLine(s.watcher),
280
297
  s.partial ? note('Partial: one of Shipping’s sources did not answer; its tile says why. The others are real.') : null,
281
298
  s.repoKnown ? null : note('GitHub links are off: the repository name comes from the Pace read, which did not answer.'),
282
- list, foot,
299
+ // While stopped the way back on sits above the list, not under a long list of pull requests.
300
+ s.stopped ? [foot, list] : [list, foot],
283
301
  ];
284
302
  }
285
303
 
@@ -14,7 +14,8 @@ export function el(tag, attrs = {}, ...kids) {
14
14
  else if (k.startsWith('on') && typeof v === 'function') n.addEventListener(k.slice(2), v);
15
15
  else n.setAttribute(k, v === true ? '' : String(v));
16
16
  }
17
- for (const kid of kids.flat()) if (kid != null && kid !== false) n.append(kid);
17
+ // Any depth: an array left inside the kids would otherwise be stringified into the page ("[object HTMLParagraphElement]").
18
+ for (const kid of kids.flat(Infinity)) if (kid != null && kid !== false) n.append(kid);
18
19
  return n;
19
20
  }
20
21
 
@@ -204,7 +205,7 @@ export function actionButton({ label, kind = 'primary', confirm, reason, run, do
204
205
  }
205
206
 
206
207
  /** replaceChildren that skips null/false (a bare null would render as the text "null"). */
207
- export function setKids(node, ...kids) { node.replaceChildren(...kids.flat().filter((k) => k != null && k !== false)); }
208
+ export function setKids(node, ...kids) { node.replaceChildren(...kids.flat(Infinity).filter((k) => k != null && k !== false)); }
208
209
 
209
210
  // ── PIN sheet (00-patterns.md §4: one look, per-action adapters) ───────────
210
211
  // adapter 'session': POST /dashboard/unlock → operator-session token, held in page
@@ -2,7 +2,7 @@
2
2
  "schemaVersion": 1,
3
3
  "generatedFrom": "source-tree",
4
4
  "registrySha256": "db01d9f92c6aa26ca903702896b41be53722982ed07d27633237bb254c214290",
5
- "packageVersion": "1.3.1641",
5
+ "packageVersion": "1.3.1642",
6
6
  "guards": [
7
7
  {
8
8
  "ref": "docs/audits/phase-b/f10-triage.md",
@@ -1,5 +1,5 @@
1
1
  {
2
- "sha256": "f22de89e214b043f1a22c9a27a89bd7ef4a84e510d327670b7368b5922c32b23",
2
+ "sha256": "4bf2279250aa96b5cd87bd921362443247a08b29fa140a68164830a0bdcb090f",
3
3
  "registrySha256": "db01d9f92c6aa26ca903702896b41be53722982ed07d27633237bb254c214290",
4
- "packageVersion": "1.3.1641"
4
+ "packageVersion": "1.3.1642"
5
5
  }
@@ -2,5 +2,5 @@
2
2
  "sha256": "db01d9f92c6aa26ca903702896b41be53722982ed07d27633237bb254c214290",
3
3
  "articleCount": 93,
4
4
  "generatedFrom": "docs/STANDARDS-REGISTRY.md",
5
- "packageVersion": "1.3.1641"
5
+ "packageVersion": "1.3.1642"
6
6
  }
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "instar",
3
- "version": "1.3.1641",
3
+ "version": "1.3.1642",
4
4
  "description": "Coherence infrastructure for self-evolving AI agents — on the Claude Code or Codex subscription you already have.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "$schema": "./builtin-manifest.schema.json",
3
3
  "schemaVersion": 1,
4
- "generatedAt": "2026-11-04T01:55:03.705Z",
5
- "instarVersion": "1.3.1641",
4
+ "generatedAt": "2026-11-04T02:21:02.288Z",
5
+ "instarVersion": "1.3.1642",
6
6
  "entryCount": 206,
7
7
  "entries": {
8
8
  "hook:session-start": {
@@ -2,7 +2,7 @@
2
2
  "schemaVersion": 1,
3
3
  "generatedFrom": "source-tree",
4
4
  "registrySha256": "db01d9f92c6aa26ca903702896b41be53722982ed07d27633237bb254c214290",
5
- "packageVersion": "1.3.1641",
5
+ "packageVersion": "1.3.1642",
6
6
  "guards": [
7
7
  {
8
8
  "ref": "docs/audits/phase-b/f10-triage.md",
@@ -1,5 +1,5 @@
1
1
  {
2
- "sha256": "f22de89e214b043f1a22c9a27a89bd7ef4a84e510d327670b7368b5922c32b23",
2
+ "sha256": "4bf2279250aa96b5cd87bd921362443247a08b29fa140a68164830a0bdcb090f",
3
3
  "registrySha256": "db01d9f92c6aa26ca903702896b41be53722982ed07d27633237bb254c214290",
4
- "packageVersion": "1.3.1641"
4
+ "packageVersion": "1.3.1642"
5
5
  }
@@ -2,5 +2,5 @@
2
2
  "sha256": "db01d9f92c6aa26ca903702896b41be53722982ed07d27633237bb254c214290",
3
3
  "articleCount": 93,
4
4
  "generatedFrom": "docs/STANDARDS-REGISTRY.md",
5
- "packageVersion": "1.3.1641"
5
+ "packageVersion": "1.3.1642"
6
6
  }
@@ -0,0 +1,23 @@
1
+ # Upgrade Guide — vNEXT
2
+
3
+ <!-- assembled-by: assemble-next-md -->
4
+ <!-- bump: patch -->
5
+
6
+ ## What Changed
7
+
8
+ The console's Development page now says so at the top when automatic merging is stopped. A card sits above everything else on the page, reading "Automatic merging is stopped", with the same "Turn auto-merge back on" action as before (same PIN prompt, same server route, same rules on who may re-arm). The Shipping bar now reads "Shipping: auto-merge stopped, …" even when closed, and the headline can no longer say "Nothing here needs you" while nothing can merge. Inside the Shipping section the button moved above the pull request list.
9
+
10
+ Two related fixes. A link to a section of the page (`?s=shipping`, `?s=feedback`, `?s=pace`) now works on a tab that is already open; before, the section you last had open beat the link. The stray text "[object HTMLParagraphElement]" under the Feedback tiles is gone: the shared page builder only flattened one level of nested lists, so a list of notes was turned into text instead of being shown. It is fixed in the builder, so no other console page can hit it.
11
+
12
+ ## What to Tell Your User
13
+
14
+ If automatic merging is ever stopped, the Development page now tells you at the very top and gives you the button to turn it back on right there, so you no longer have to open the Shipping section and scroll past the pull request list. Links to a section of that page also work now if you already have it open, and the odd "[object HTMLParagraphElement]" text under Feedback is gone.
15
+
16
+ ## Summary of New Capabilities
17
+
18
+ - A lead card on the console Development page while auto-merging is stopped.
19
+ - `?s=<section>` links work on an already-open Development page.
20
+
21
+ ## Evidence
22
+
23
+ The new tests in `tests/unit/console-development.test.ts` cover stopped and not stopped, the link on an open tab, and the stringify bug. Four of them fail on the old page code and pass on the new. The page was rendered at 1280 wide from the branch against this agent's live reads through a proxy that refuses every write: stopped (the card leads, the Shipping bar says stopped, the button is at about y=345 instead of y=1575 on a closed fold) and not stopped (with the watcher answer altered in the proxy only: no card, no stopped words). The live stop was not touched.
@@ -0,0 +1,11 @@
1
+ # The Development page says when automatic merging is stopped
2
+
3
+ **What this changes, in plain words.** Automatic merging can be stopped, by the operator or by the agent. While it is stopped, pull requests that pass their checks just wait. The console's Development page barely showed this. On first load it often opens the Feedback section, so Shipping stays a closed bar, and the only notice and the only button to turn merging back on sat at the bottom of that closed section, under the whole pull request list. The operator hit this three times.
4
+
5
+ Now, when merging is stopped, a card at the very top of the page says so and carries the button. The Shipping bar says it too, and the headline stops claiming nothing needs you. When merging is not stopped the page looks as it did.
6
+
7
+ **What did not change.** The button does exactly what it did: it asks for the PIN and sends it once to the same server route with the same stop records. Nothing here changes who may turn merging back on, and the server still makes that decision. There is no new route.
8
+
9
+ **Two small fixes in the same page.** A link to one section now opens that section even if the page is already open. It applies once per arrival, so the 30-second refresh does not undo what the operator opens or closes, or scroll the page again. And the stray text "[object HTMLParagraphElement]" under Feedback is gone. A list of notes nested inside another list was being turned into text, because the shared page builder only flattened one level. Fixing the builder removes that mistake from every console page.
10
+
11
+ **If it is wrong.** Revert the commit. It only changes browser-side files, with no stored state or settings.
@@ -0,0 +1,55 @@
1
+ # Side-Effects Review — Development page shows when auto-merge is stopped
2
+
3
+ **Version / slug:** `console-development-stopped-lead`
4
+ **Date:** `2026-11-03`
5
+ **Author:** `Echo`
6
+ **Second-pass reviewer:** `not required`
7
+
8
+ ## Summary of the change
9
+
10
+ Tier 1: 4 source and test files, about 250 lines, most of them tests; one review round, one commit attempt expected. `dashboard/console/development-model.js` gains `stoppedLead` and `foldFromHash`, says stopped in `Shipping.line` and tone, and makes the headline lead with the stop. `dashboard/console/development.js` renders the lead card above other leads, moves the fold's button above the list, shares one `backOnButton` calling the unchanged `enableAutoMerge`, applies `?s=` once per arrival, and scrolls once per arrival. `dashboard/console/ui.js` flattens nested kids at any depth in `el` and `setKids`, the source of the "[object HTMLParagraphElement]" text.
11
+
12
+ ## Decision-point inventory
13
+
14
+ No decision-point surface. `shipping.stopped` already existed and is only read for display. The re-arm still goes through the server's PIN gate on `/green-pr-automerge/enable`; its request body is unchanged and pinned by a test.
15
+
16
+ ---
17
+
18
+ ## 1. Over-block
19
+
20
+ No block/allow surface — over-block not applicable.
21
+
22
+ ## 2. Under-block
23
+
24
+ No block/allow surface. As a signal: if the watcher read fails the page cannot know, so there is no card; the Shipping bar and tiles say "can't tell" as before, never a calm "not stopped". An unknown `?s=` value names no section and leaves the remembered one alone (it used to leave every fold shut).
25
+
26
+ ## 3. Level-of-abstraction fit
27
+
28
+ The decision lives in the view-model as this page's rule requires, and the card only renders it. The stringify fix is in the shared builder rather than at the one `feedbackBody` call site, because any page can nest a mapped list in the same way; the change only affects arrays that would otherwise be stringified.
29
+
30
+ ## 4. Signal vs authority compliance
31
+
32
+ Reference: `docs/signal-vs-authority.md`. This is display only and holds no blocking authority. The server remains the only authority on re-arming.
33
+
34
+ ## 5. Interactions
35
+
36
+ - PR 2678 (agent-stop-release) adds a wording branch inside `watcherLine` and test lines in another `describe`. The card shows `watcher.text` as its "who stopped it" line, so 2678's wording reaches the card with no further change. A trial merge of the two is clean.
37
+ - The 30-second refresh calls `renderDevelopment` on the same `<main>`, so `.dev-page` exists and `?s=` is not reapplied. Every route change builds a new `<main>`, which is the arrival signal.
38
+ - Scrolling on every repaint when the address held `?s=` is gone; it would have pulled the page back on refresh or fold tap.
39
+ - `developmentHeadline` now counts the stop as one thing needing the operator, and adds Shipping's needs-you rows the same way it did before.
40
+
41
+ ## 6. External surfaces
42
+
43
+ Maintainer-only console page text and layout. No API, message or route changes. The card's button is the same action as the existing one.
44
+
45
+ ## 7. Multi-machine posture
46
+
47
+ Machine-local by design, as the page already was: it reads the local `/green-pr-automerge`. A standby machine shows what its own read says.
48
+
49
+ ## 8. Rollback cost
50
+
51
+ Revert the commit and release. Browser-side files only, with no state, config or migration.
52
+
53
+ ## Evidence pointers
54
+
55
+ `tests/unit/console-development.test.ts`: four new tests fail on the old page code and pass on the new. Browser proof at 1280 wide, stopped and not stopped, through a proxy that refused every write; the live latch was not touched and no PIN was used.