instar 1.3.519 → 1.3.520

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.
@@ -815,18 +815,34 @@
815
815
  .mnd-bad { color: #f85149; border-color: #f8514955; font-weight: 700; }
816
816
  .mnd-deny { color: #d29922; border-color: #d2992255; }
817
817
  .mnd-dead { color: var(--text-dim); }
818
- .mnd-meta { font-size: 13px; color: var(--text-dim); }
819
- .mnd-authorities { margin: 0; padding-left: 18px; font-size: 13px; line-height: 1.7; }
818
+ /* Internals (id, fingerprints, scope slug, action slugs) are SUPPORT
819
+ metadata only de-emphasized, never the headline (Operator-Surface
820
+ Quality item 2). */
821
+ .mnd-meta { font-size: 12px; color: var(--text-dim); }
822
+ /* The card's plain-language headline sentence — the primary content. */
823
+ .mnd-summary { margin: 0; font-size: 15px; line-height: 1.5; color: var(--text-bright); }
820
824
  .mnd-empty { font-size: 16px; color: var(--text-dim); line-height: 1.6; padding: 12px 0; }
821
- .mnd-revoke-row { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
825
+ .mnd-revoke-row { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; margin: 8px 0 4px; }
822
826
  .mnd-pin { width: 110px; } .mnd-reason { flex: 1; min-width: 160px; }
823
827
  .mnd-btn { padding: 6px 14px; cursor: pointer; }
824
- .mnd-btn-danger { color: #f85149; }
828
+ .mnd-btn-primary { font-weight: 600; }
829
+ /* Destructive action: quiet by design, never featured (item 3). */
830
+ .mnd-btn-danger { color: #f85149; font-weight: 400; }
825
831
  .mnd-dead-note { font-size: 13px; color: var(--text-dim); font-style: italic; }
826
832
  .mnd-grants-head { font-size: 13px; color: var(--text-dim); }
827
- .mnd-grants { margin: 0; padding-left: 18px; font-size: 13px; line-height: 1.7; }
828
- .mnd-grant-details { font-size: 13px; }
829
- .mnd-grant-summary { cursor: pointer; color: var(--text-dim); }
833
+ .mnd-grants { margin: 0; padding-left: 18px; font-size: 14px; line-height: 1.7; }
834
+ .mnd-grant-id { color: var(--text-dim); font-size: 12px; }
835
+ /* The grant form is the card's PRIMARY action — an open, titled block, never
836
+ a collapsed toggle (Operator-Surface Quality item 1). */
837
+ .mnd-grant-block { border-top: 1px solid var(--border); padding-top: 10px; margin-top: 2px; }
838
+ .mnd-grant-title { font-size: 14px; font-weight: 600; color: var(--text-bright); margin-bottom: 6px; }
839
+ .mnd-grant-sep { font-size: 13px; color: var(--text-dim); }
840
+ /* Revoke is demoted to a quiet, collapsed secondary control (item 3). */
841
+ .mnd-revoke-details { font-size: 13px; margin-top: 4px; }
842
+ .mnd-revoke-summary { cursor: pointer; color: var(--text-dim); font-size: 13px; }
843
+ .mnd-about { font-size: 14px; color: var(--text-dim); margin-top: -8px; }
844
+ .mnd-about summary { cursor: pointer; }
845
+ .mnd-about p { margin: 8px 0 0; line-height: 1.55; }
830
846
  .mnd-grant-row { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; margin: 8px 0 4px; }
831
847
  .mnd-grant-field { min-width: 130px; max-width: 100%; padding: 6px 8px; }
832
848
  .mnd-notice { min-height: 20px; font-size: 14px; color: #3fb950; }
@@ -842,9 +858,19 @@
842
858
  .mnd-notice-ok { border: 1px solid #15803d; background: rgba(21, 128, 61, 0.12); color: #86efac; padding: 12px 14px; border-radius: 8px; font-size: 17px; font-weight: 600; line-height: 1.5; }
843
859
  .mnd-table { width: 100%; border-collapse: collapse; font-size: 13px; }
844
860
  .mnd-table th, .mnd-table td { text-align: left; padding: 6px 8px; border-bottom: 1px solid var(--border); vertical-align: top; }
845
- .mnd-reason-cell { color: var(--text-dim); max-width: 420px; }
846
- .mnd-audit-head { display: flex; gap: 12px; align-items: center; margin-bottom: 8px; }
861
+ .mnd-reason-cell { color: var(--text-dim); }
862
+ .mnd-audit-head { display: flex; gap: 12px; align-items: center; margin-bottom: 8px; flex-wrap: wrap; }
847
863
  .mnd-dim { font-size: 13px; color: var(--text-dim); }
864
+ /* Mobile-first (item 5): at phone width the 5-column audit table stacks into
865
+ labelled rows so the reason — the useful column — is never truncated. */
866
+ @media (max-width: 640px) {
867
+ .mnd-table thead { position: absolute; left: -9999px; }
868
+ .mnd-table tr { display: block; border: 1px solid var(--border); border-radius: 8px; margin-bottom: 10px; padding: 6px 4px; }
869
+ .mnd-table td { display: flex; justify-content: space-between; gap: 12px; border-bottom: none; padding: 4px 8px; }
870
+ .mnd-table td::before { content: attr(data-label); font-weight: 600; color: var(--text-dim); flex: 0 0 auto; }
871
+ .mnd-reason-cell { text-align: right; word-break: break-word; }
872
+ .mnd-grant-field, .mnd-pin, .mnd-reason { width: 100%; min-width: 0; flex: 1 1 100%; }
873
+ }
848
874
 
849
875
  /* ── process-health tab (PROCESS-HEALTH-DASHBOARD-TAB-SPEC §4.1) ──
850
876
  Large, readable, calm. No monospace, no tables, no alarm colors.
@@ -3384,7 +3410,11 @@
3384
3410
  <h2 class="ph-title">Coordination Mandates</h2>
3385
3411
  <div id="mndStamp" class="ph-stamp" aria-live="polite"></div>
3386
3412
  </div>
3387
- <p class="ph-intro">Your bounded "permission slips" for autonomous agent-to-agent work. The slip — never the agent — is the authorizer. Issuing and revoking require <strong>your dashboard PIN</strong>; agent credentials alone are refused by design. With no slip issued, every delegated action is denied.</p>
3413
+ <p class="ph-intro">Permission slips you sign with your PIN to let your agents do bounded work together.</p>
3414
+ <details class="mnd-about">
3415
+ <summary>What is this?</summary>
3416
+ <p>These are your permission slips for autonomous agent-to-agent work — each one authorizes a specific, bounded task. The slip — never the agent — is the authorizer. Issuing and revoking require <strong>your dashboard PIN</strong>; an agent's own credentials are refused by design. With no slip issued, every delegated action is denied.</p>
3417
+ </details>
3388
3418
  <div id="mndNotice" class="mnd-notice" aria-live="polite"></div>
3389
3419
  <section class="ph-section">
3390
3420
  <h3 class="ph-h">Issued mandates</h3>
@@ -61,12 +61,72 @@ function fmtWhen(iso) {
61
61
  } catch { return String(iso); }
62
62
  }
63
63
 
64
- function userLabel(slackUserId, slackUsers) {
64
+ /** The registry name for a Slack id, or the id itself when we don't know them. */
65
+ function userName(slackUserId, slackUsers) {
65
66
  const u = (slackUsers || []).find((x) => x.slackUserId === slackUserId);
66
- return u ? `${u.name} (${slackUserId})` : slackUserId;
67
+ return u ? u.name : slackUserId;
67
68
  }
68
69
 
69
- /** The grants a mandate already carries, in operator language. */
70
+ // Operator-Surface Quality (constitution): the card speaks plain language, never
71
+ // the slugs/JSON the data model stores. These maps turn the stored enum/bounds
72
+ // into the sentence a non-engineer would say. Unknown values fall back to a
73
+ // de-slugified phrase — never a raw token dumped at the operator.
74
+
75
+ // Both the coordination-mandate authority actions AND the RolePolicy floor
76
+ // actions, in plain language. Returns the RAW human string; callers esc() it.
77
+ const ACTION_PHRASES = {
78
+ 'exchange-read-credential': 'exchange a read-only credential',
79
+ 'sign-code-review': 'co-sign a code review',
80
+ 'money-movement': 'move money',
81
+ 'prod-deploy': 'deploy to production',
82
+ 'credential-access': 'use a credential',
83
+ 'destructive-data': 'run a destructive data operation',
84
+ 'external-send': 'send something externally',
85
+ 'grant-authority': 'grant authority to others',
86
+ };
87
+ function humanAction(slug) {
88
+ const s = String(slug ?? '');
89
+ return ACTION_PHRASES[s] || s.replace(/-/g, ' ');
90
+ }
91
+
92
+ /** "you" when the authorizer is the operator-via-PIN; otherwise the raw name. */
93
+ function humanAuthorizer(authorizedBy) {
94
+ const s = String(authorizedBy ?? '');
95
+ return /operator|dashboard pin/i.test(s) ? 'you' : s;
96
+ }
97
+
98
+ /** A short title from the operator's scope name (de-slugified, not the raw slug). */
99
+ function humanScope(scope) {
100
+ const s = String(scope ?? '').trim();
101
+ if (!s) return 'Permission slip';
102
+ const words = s.replace(/[-_]+/g, ' ');
103
+ return words.charAt(0).toUpperCase() + words.slice(1);
104
+ }
105
+
106
+ /** Join phrases the way a person speaks them: "a, b and c". */
107
+ function joinHuman(parts) {
108
+ if (parts.length === 0) return '';
109
+ if (parts.length === 1) return parts[0];
110
+ return parts.slice(0, -1).join(', ') + ' and ' + parts[parts.length - 1];
111
+ }
112
+
113
+ /**
114
+ * One plain sentence describing what the mandate authorizes + when it ends —
115
+ * the card's primary, human-language headline (no slugs, no JSON, no
116
+ * fingerprints). Each interpolated action is escaped at the call site.
117
+ */
118
+ function humanSummary(m) {
119
+ const acts = (m.authorities || []).map((a) => esc(humanAction(a.action)));
120
+ const agents = (m.agents || []).filter(Boolean);
121
+ const who = agents.length >= 2 ? 'two agents' : 'an agent';
122
+ const when = fmtWhen(m.expiresAt);
123
+ if (acts.length === 0) {
124
+ return `A permission slip for ${who} that delegates no actions yet. Expires ${when}.`;
125
+ }
126
+ return `Lets ${who} ${joinHuman(acts)}. Expires ${when}.`;
127
+ }
128
+
129
+ /** The grants a mandate already carries, in plain operator language. */
70
130
  export function renderGrants(m, slackUsers) {
71
131
  const grants = Array.isArray(m.grants) ? m.grants : [];
72
132
  if (grants.length === 0) return '';
@@ -75,16 +135,22 @@ export function renderGrants(m, slackUsers) {
75
135
  const badge = expired
76
136
  ? '<span class="mnd-badge mnd-dead">expired</span>'
77
137
  : '<span class="mnd-badge mnd-ok">active</span>';
78
- return `<li>${badge} <strong>${esc(userLabel(g.grantedTo, slackUsers))}</strong> may <code>${esc(g.floorAction)}</code> until ${fmtWhen(g.expiresAt)} — authorized by ${esc(g.authorizedBy)}</li>`;
138
+ const name = userName(g.grantedTo, slackUsers);
139
+ // The Slack id is support metadata, never the headline — shown muted only
140
+ // when it differs from the name we render (an unknown id IS the name).
141
+ const idDetail = name !== g.grantedTo ? ` <span class="mnd-grant-id">(${esc(g.grantedTo)})</span>` : '';
142
+ return `<li>${badge} <strong>${esc(name)}</strong>${idDetail} can ${esc(humanAction(g.floorAction))} until ${fmtWhen(g.expiresAt)} — authorized by ${esc(humanAuthorizer(g.authorizedBy))}</li>`;
79
143
  }).join('');
80
- return `<div class="mnd-grants-head">User grants this mandate carries:</div><ul class="mnd-grants">${rows}</ul>`;
144
+ return `<div class="mnd-grants-head">Who this slip already lets act:</div><ul class="mnd-grants">${rows}</ul>`;
81
145
  }
82
146
 
83
147
  /**
84
- * The add-grant form for an ACTIVE mandate. Mobile-first by design (the
85
- * 2026-06-12 lesson, instar#1080): the operator PICKS a person and a duration —
86
- * the only thing typed is the PIN. A free-text Slack-id input appears only
87
- * when the user registry has nobody to offer.
148
+ * The add-grant form for an ACTIVE mandate the card's PRIMARY action, always
149
+ * visible (Operator-Surface Quality: lead with the primary action, never behind
150
+ * a toggle). Mobile-first (the 2026-06-12 lesson, instar#1080): the operator
151
+ * PICKS a person and a duration — the only thing typed is the PIN. A free-text
152
+ * Slack-id input appears only when the user registry has nobody to offer. Action
153
+ * labels read in plain language; the slug is the option VALUE the server needs.
88
154
  */
89
155
  export function renderGrantForm(m, slackUsers) {
90
156
  const users = (slackUsers || []).filter((u) => u.slackUserId);
@@ -94,21 +160,23 @@ export function renderGrantForm(m, slackUsers) {
94
160
  }</select>`
95
161
  : `<input type="text" class="mnd-grant-field" data-grant-user="${esc(m.id)}" placeholder="Slack user id (e.g. U0…)" />`;
96
162
  const actionField = `<select class="mnd-grant-field" data-grant-action="${esc(m.id)}">${
97
- FLOOR_ACTIONS.map((a) => `<option value="${a}"${a === 'prod-deploy' ? ' selected' : ''}>${a}</option>`).join('')
163
+ FLOOR_ACTIONS.map((a) => `<option value="${a}"${a === 'prod-deploy' ? ' selected' : ''}>${esc(humanAction(a))}</option>`).join('')
98
164
  }</select>`;
99
165
  const durationField = `<select class="mnd-grant-field" data-grant-duration="${esc(m.id)}">${
100
166
  GRANT_DURATIONS.map((d) => `<option value="${d.minutes}"${d.minutes === 60 ? ' selected' : ''}>${d.label}</option>`).join('')
101
167
  }</select>`;
102
- return `<details class="mnd-grant-details"><summary class="mnd-grant-summary">Grant a user a floor action (PIN required)</summary>
168
+ return `<div class="mnd-grant-block">
169
+ <div class="mnd-grant-title">Grant a person an action</div>
103
170
  <div class="mnd-grant-row">
104
171
  ${granteeField}
105
172
  ${actionField}
173
+ <span class="mnd-grant-sep">for</span>
106
174
  ${durationField}
107
- <input type="password" class="mnd-pin" data-grant-pin="${esc(m.id)}" placeholder="PIN" autocomplete="off" />
108
- <button class="mnd-btn" data-grant="${esc(m.id)}">Grant</button>
175
+ <input type="password" class="mnd-pin" data-grant-pin="${esc(m.id)}" placeholder="Your PIN" autocomplete="off" />
176
+ <button class="mnd-btn mnd-btn-primary" data-grant="${esc(m.id)}">Grant</button>
109
177
  </div>
110
- <span class="mnd-hint">Lifts the picked person to ONE floor action for the picked window. Signed into this mandate by your PIN — revoking the mandate voids it; it can never outlive the mandate.</span>
111
- </details>`;
178
+ <span class="mnd-hint">Lets the person you pick take that one action for the window you choose. Confirmed with your PIN — never stored. A grant can never outlive this slip, and revoking the slip ends it.</span>
179
+ </div>`;
112
180
  }
113
181
 
114
182
  export function renderMandates(list, slackUsers = []) {
@@ -119,30 +187,39 @@ export function renderMandates(list, slackUsers = []) {
119
187
  const expired = Date.parse(m.expiresAt) < Date.now();
120
188
  const state = m.revoked ? 'revoked' : expired ? 'expired' : 'active';
121
189
  const stateCls = state === 'active' ? 'mnd-ok' : 'mnd-dead';
190
+ const stateLabel = state === 'active' ? 'active' : state === 'revoked' ? 'revoked' : 'expired';
122
191
  const authBadge = m.authorshipValid
123
192
  ? '<span class="mnd-badge mnd-ok">authorship verified</span>'
124
193
  : '<span class="mnd-badge mnd-bad">AUTHORSHIP INVALID</span>';
125
- const authorities = (m.authorities || []).map((a) =>
126
- `<li><code>${esc(a.action)}</code> bounds <code>${esc(JSON.stringify(a.bounds))}</code>${a.requiresCondition ? `requires <code>${esc(a.requiresCondition)}</code>` : ''}</li>`,
127
- ).join('');
128
- const revokeUi = state === 'active'
129
- ? `<div class="mnd-revoke-row">
130
- <input type="password" class="mnd-pin" data-revoke-pin="${esc(m.id)}" placeholder="PIN" autocomplete="off" />
131
- <input type="text" class="mnd-reason" data-revoke-reason="${esc(m.id)}" placeholder="reason" />
132
- <button class="mnd-btn mnd-btn-danger" data-revoke="${esc(m.id)}">Revoke</button>
133
- </div>`
134
- : `<div class="mnd-dead-note">${m.revoked ? `revoked ${fmtWhen(m.revoked.at)} — ${esc(m.revoked.reason)}` : `expired ${fmtWhen(m.expiresAt)}`}</div>`;
194
+ // Internals (id, agent fingerprints, scope slug, raw action names, bounds)
195
+ // are SUPPORT metadata, never the headline kept on one muted line so the
196
+ // operator can quote an id when asking for help, but never has to read JSON.
197
+ const actionSlugs = (m.authorities || []).map((a) => esc(a.action)).filter(Boolean).join(', ') || 'none';
198
+ const referenceLine = `<div class="mnd-meta">For support: id <code>${esc(m.id)}</code> · agents <code>${esc(shortFp(m.agents?.[0]))}</code> + <code>${esc(shortFp(m.agents?.[1]))}</code> · scope <code>${esc(m.scope)}</code> · authorizes ${actionSlugs} · issued by ${esc(m.author)}</div>`;
199
+ // The grant form is the PRIMARY action and renders ABOVE revoke. Revoke is
200
+ // destructive, so it is demoted to a quiet collapsed control, never featured.
135
201
  const grantUi = state === 'active' ? renderGrantForm(m, slackUsers) : '';
202
+ const revokeUi = state === 'active'
203
+ ? `<details class="mnd-revoke-details">
204
+ <summary class="mnd-revoke-summary">Revoke this permission slip</summary>
205
+ <div class="mnd-revoke-row">
206
+ <input type="password" class="mnd-pin" data-revoke-pin="${esc(m.id)}" placeholder="Your PIN" autocomplete="off" />
207
+ <input type="text" class="mnd-reason" data-revoke-reason="${esc(m.id)}" placeholder="reason (optional)" />
208
+ <button class="mnd-btn mnd-btn-danger" data-revoke="${esc(m.id)}">Revoke</button>
209
+ </div>
210
+ <span class="mnd-hint">Ends the slip and everything it authorized, right away. This cannot be undone.</span>
211
+ </details>`
212
+ : `<div class="mnd-dead-note">${m.revoked ? `Revoked ${fmtWhen(m.revoked.at)} — ${esc(m.revoked.reason)}` : `Expired ${fmtWhen(m.expiresAt)}`}</div>`;
136
213
  return `<div class="mnd-card">
137
214
  <div class="mnd-card-head">
138
- <span class="mnd-scope">${esc(m.scope)}</span>
139
- <span class="mnd-badge ${stateCls}">${state}</span>
215
+ <span class="mnd-scope">${esc(humanScope(m.scope))}</span>
216
+ <span class="mnd-badge ${stateCls}">${stateLabel}</span>
140
217
  ${authBadge}
141
218
  </div>
142
- <div class="mnd-meta">id <code>${esc(m.id)}</code> · agents <code>${esc(shortFp(m.agents?.[0]))}</code> + <code>${esc(shortFp(m.agents?.[1]))}</code> · by ${esc(m.author)} · expires ${fmtWhen(m.expiresAt)}</div>
143
- <ul class="mnd-authorities">${authorities}</ul>
219
+ <p class="mnd-summary">${humanSummary(m)}</p>
144
220
  ${renderGrants(m, slackUsers)}
145
221
  ${grantUi}
222
+ ${referenceLine}
146
223
  ${revokeUi}
147
224
  </div>`;
148
225
  }).join('');
@@ -157,13 +234,17 @@ export function renderAudit(payload) {
157
234
  if (entries.length === 0) {
158
235
  return `<div class="mnd-audit-head">${chainBadge}</div><div class="mnd-empty">No decisions recorded yet.</div>`;
159
236
  }
237
+ // Mobile-first (Operator-Surface Quality item 5): each cell carries a
238
+ // data-label so the table can stack into labelled rows at phone width (the
239
+ // CSS media query in index.html), instead of cramming five columns and
240
+ // truncating the most useful one — the reason — into an unreadable sliver.
160
241
  const rows = entries.slice(-25).reverse().map((e) =>
161
242
  `<tr>
162
- <td>${fmtWhen(e.ts)}</td>
163
- <td><span class="mnd-badge ${e.decision === 'allow' ? 'mnd-ok' : 'mnd-deny'}">${esc(e.decision)}</span></td>
164
- <td><code>${esc(e.action)}</code></td>
165
- <td><code>${esc(shortFp(e.agentFp))}</code></td>
166
- <td class="mnd-reason-cell">${esc(e.reason)}</td>
243
+ <td data-label="When">${fmtWhen(e.ts)}</td>
244
+ <td data-label="Decision"><span class="mnd-badge ${e.decision === 'allow' ? 'mnd-ok' : 'mnd-deny'}">${esc(e.decision)}</span></td>
245
+ <td data-label="Action"><code>${esc(e.action)}</code></td>
246
+ <td data-label="Agent"><code>${esc(shortFp(e.agentFp))}</code></td>
247
+ <td data-label="Reason" class="mnd-reason-cell">${esc(e.reason)}</td>
167
248
  </tr>`,
168
249
  ).join('');
169
250
  return `<div class="mnd-audit-head">${chainBadge}<span class="mnd-dim">${entries.length} total decisions · newest first</span></div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "instar",
3
- "version": "1.3.519",
3
+ "version": "1.3.520",
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",
@@ -32,6 +32,7 @@ import { checkEli16Overview, MIN_ELI16_CHARS } from './eli16-overview-check.mjs'
32
32
  import { verifyProposalDerivedRunbooks } from '../skills/instar-dev/scripts/verify-proposal-derived-runbook.mjs';
33
33
  import { classifyTier, decideRequirementSet } from './lib/classify-tier.mjs';
34
34
  import { recognizeConvergence } from './lib/convergence-recognition.mjs';
35
+ import { isOperatorSurfaceFile, artifactAddressesOperatorSurfaceQuality } from './lib/operator-surface.mjs';
35
36
 
36
37
  // Report-Backed Converging Audit (docs/specs/CONVERGING-AUDIT-DEFAULT.md, Part B).
37
38
  // The precommit reads NO config file and runs pre-compile, so it cannot import
@@ -884,6 +885,7 @@ if (!promotionGateResult.ok) {
884
885
  // ─── Pass ────────────────────────────────────────────────────────────────
885
886
 
886
887
  assertFrameworkGenerality(inScopeFiles, validTrace.trace);
888
+ assertOperatorSurfaceQuality(staged, validTrace.trace);
887
889
 
888
890
  console.error(
889
891
  `[instar-dev-precommit] OK — trace ${path.basename(validTrace.entry.file)} covers ${inScopeFiles.length} in-scope file(s), artifact ${validTrace.trace.artifactPath} verified, spec ${spec} is converged + approved` +
@@ -930,6 +932,49 @@ function assertFrameworkGenerality(inScopeFiles, trace) {
930
932
  }
931
933
  }
932
934
 
935
+ // Operator-Surface Quality review gate (docs/STANDARDS-REGISTRY.md →
936
+ // "Operator-Surface Quality", CMT-1434). A change touching an operator surface
937
+ // (dashboard renderers/markup, approval pages, grant/secret forms) must answer
938
+ // the operator-surface-quality question in the side-effects artifact IN WRITING:
939
+ // does the surface lead with its primary action, expose zero raw internals,
940
+ // de-emphasize destructive actions, and work at phone width? A "reachable but
941
+ // bad" surface passes Mobile-Complete and still fails the operator (the
942
+ // 2026-06-12 "abysmal" Mandates-grant-form lesson) — this makes the quality
943
+ // question unskippable. Operator-surface files are NOT in the gate's inScope set
944
+ // (they live under dashboard/), so we scan the full STAGED set, not inScopeFiles.
945
+ // Scoped tight so a non-surface commit pays nothing. Companion to
946
+ // assertFrameworkGenerality.
947
+ function assertOperatorSurfaceQuality(stagedFiles, trace) {
948
+ const touched = (stagedFiles || []).filter(isOperatorSurfaceFile);
949
+ if (touched.length === 0) return;
950
+ const artifactRel = trace.sideEffectsPath || trace.artifactPath;
951
+ if (!artifactRel) return; // a missing artifact is already blocked upstream
952
+ const artifactAbs = path.resolve(ROOT, artifactRel);
953
+ if (!fs.existsSync(artifactAbs)) return;
954
+ const content = fs.readFileSync(artifactAbs, 'utf8');
955
+ // The artifact must engage the operator-surface-quality question (the §6b
956
+ // section seeded by skills/instar-dev/templates/side-effects-artifact.md).
957
+ if (!artifactAddressesOperatorSurfaceQuality(content)) {
958
+ blockCommit(
959
+ touched,
960
+ [
961
+ 'Operator-Surface Quality review gate:',
962
+ ` ${touched.join(', ')} change an OPERATOR SURFACE,`,
963
+ ' but the side-effects artifact never answers the operator-surface-quality',
964
+ ' question — a surface can be phone-reachable (Mobile-Complete) and still be',
965
+ ' unusable (the 2026-06-12 "abysmal" Mandates grant-form lesson, CMT-1434).',
966
+ '',
967
+ ' Add the "## 6b. Operator-surface quality" section and answer in writing:',
968
+ ' does the surface (1) lead with its primary action, (2) expose zero raw',
969
+ ' internals as primary content, (3) de-emphasize destructive actions, and',
970
+ ' (4) read in plain language at phone width? (Standard:',
971
+ ' docs/STANDARDS-REGISTRY.md → "Operator-Surface Quality". Template:',
972
+ ' skills/instar-dev/templates/side-effects-artifact.md §6b.)',
973
+ ].join('\n'),
974
+ );
975
+ }
976
+ }
977
+
933
978
  function blockCommit(files, reason) {
934
979
  console.error('');
935
980
  console.error('╔════════════════════════════════════════════════════════════════════╗');
@@ -1125,6 +1170,7 @@ function enforceTier1(trace, traceFile) {
1125
1170
  }
1126
1171
 
1127
1172
  assertFrameworkGenerality(inScopeFiles, trace);
1173
+ assertOperatorSurfaceQuality(staged, trace);
1128
1174
 
1129
1175
  console.error(
1130
1176
  `[instar-dev-precommit] OK (Tier 1) — trace ${traceName} covers ${inScopeFiles.length} in-scope file(s), ` +
@@ -0,0 +1,54 @@
1
+ /**
2
+ * operator-surface.mjs — pure decision logic for the Operator-Surface Quality
3
+ * review gate in the instar-dev commit gate (docs/STANDARDS-REGISTRY.md →
4
+ * "Operator-Surface Quality", CMT-1434).
5
+ *
6
+ * Two pure predicates, extracted so the gate's load-bearing decisions are
7
+ * unit-testable without git/fs mocking (the classify-tier.mjs pattern):
8
+ *
9
+ * - isOperatorSurfaceFile(path): is this staged file an operator surface — a
10
+ * dashboard renderer/markup file, an approval page, or a grant/secret form?
11
+ * - artifactAddressesOperatorSurfaceQuality(content): does the side-effects
12
+ * artifact engage the operator-surface-quality question in writing?
13
+ *
14
+ * SIGNAL-FREE: these never block; the gate (instar-dev-precommit.js) reads them
15
+ * and decides. Keeping them pure means both sides of every boundary are pinned
16
+ * by tests (Testing Integrity → semantic-correctness).
17
+ */
18
+
19
+ /**
20
+ * An operator surface is anything a person uses to authorize, decide, or act:
21
+ * - dashboard renderer / markup files (dashboard/*.js, dashboard/*.html), AND
22
+ * - approval pages / one-time-approval links / secret-drop forms (a file whose
23
+ * basename starts with approval / operator-approval / secret-drop).
24
+ * Build/test/spec siblings (*.test.js, *.spec.js) are NOT surfaces.
25
+ */
26
+ export const OPERATOR_SURFACE_RE =
27
+ /^dashboard\/.+\.(?:js|html)$|(?:^|\/)(?:approval|operator-approval|secret-drop)[^/]*\.(?:js|html|ts)$/i;
28
+
29
+ /**
30
+ * True when a staged file path is an operator surface.
31
+ * @param {string} file repo-relative path
32
+ * @returns {boolean}
33
+ */
34
+ export function isOperatorSurfaceFile(file) {
35
+ const f = String(file ?? '');
36
+ if (!f) return false;
37
+ // A test/spec file that happens to live under dashboard/ or match the approval
38
+ // basename is NOT itself an operator surface — it's a guard for one.
39
+ if (/\.(?:test|spec)\.(?:js|ts|mjs)$/.test(f)) return false;
40
+ return OPERATOR_SURFACE_RE.test(f);
41
+ }
42
+
43
+ /**
44
+ * The artifact engages the operator-surface-quality question when it carries the
45
+ * §6b heading/phrase (seeded by side-effects-artifact.md). The gate's job is to
46
+ * ensure the question is structurally PRESENT for every operator-surface change —
47
+ * an agent that deletes/skips the section (or writes a bespoke artifact omitting
48
+ * it) is blocked. (Same strength as the framework-generality gate.)
49
+ * @param {string} content the side-effects artifact text
50
+ * @returns {boolean}
51
+ */
52
+ export function artifactAddressesOperatorSurfaceQuality(content) {
53
+ return /operator[- ]surface quality/i.test(String(content ?? ''));
54
+ }
@@ -101,6 +101,21 @@
101
101
 
102
102
  ---
103
103
 
104
+ ## 6b. Operator-surface quality (Operator-Surface Quality standard)
105
+
106
+ **REQUIRED whenever this change touches an operator surface** — a dashboard renderer/markup file (`dashboard/*.js`, `dashboard/*.html`), an approval page, or a grant/revoke/secret-drop form. The pre-commit gate (`scripts/instar-dev-precommit.js`) refuses the commit if this section is missing when an operator-surface file is staged. Reachable-but-bad still fails the operator (the 2026-06-12 "abysmal" Mandates-grant-form lesson, CMT-1434): Mobile-Complete asks *can they do it from a phone?*; this asks *is it good when they do?*
107
+
108
+ Answer each in writing (a "no" or unjustified "n/a" blocks the commit):
109
+
110
+ 1. **Leads with the primary action?** The thing the operator came to do is visible and actionable on arrival — never collapsed behind a toggle, below the fold, or after explanatory prose.
111
+ 2. **Zero raw internals as primary content?** No JSON blobs, fingerprints, UUIDs, hashes, or enum/slug values shown as headline content — only human language; identifiers de-emphasized as support metadata when genuinely needed.
112
+ 3. **Destructive actions de-emphasized?** Revoke/delete/stop is visually quieter than the constructive primary action and never appears above it.
113
+ 4. **Plain language + phone width?** Labels/states read the way a non-engineer would say them; verified at phone width — real tap targets, readable type, no horizontal scroll, no truncated table hiding the answer.
114
+
115
+ [Specific findings per criterion. "Grant form renders open as the card's primary block (mnd-grant-block); Revoke demoted to a collapsed mnd-revoke-details below it; bounds/fingerprints humanized, raw ids kept only on the muted 'For support' line; audit table stacks at ≤640px." If this change touches NO operator surface, state: "No operator surface — not applicable."]
116
+
117
+ ---
118
+
104
119
  ## 7. Multi-machine posture (Cross-Machine Coherence)
105
120
 
106
121
  **When this agent runs on MORE THAN ONE machine, what is this feature's posture?**
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "$schema": "./builtin-manifest.schema.json",
3
3
  "schemaVersion": 1,
4
- "generatedAt": "2026-06-13T09:03:12.807Z",
5
- "instarVersion": "1.3.519",
4
+ "generatedAt": "2026-06-13T09:21:46.780Z",
5
+ "instarVersion": "1.3.520",
6
6
  "entryCount": 201,
7
7
  "entries": {
8
8
  "hook:session-start": {
@@ -0,0 +1,50 @@
1
+ # Upgrade Guide — vNEXT
2
+
3
+ <!-- assembled-by: assemble-next-md -->
4
+ <!-- bump: minor -->
5
+
6
+ ## What Changed
7
+
8
+ Two cohesive changes (topic 22367, CMT-1434):
9
+
10
+ 1. **Mandates dashboard tab redesign.** The Mandates card now leads with the Grant
11
+ form as its primary, always-open block; Revoke is demoted to a quiet, collapsed
12
+ control beneath it. Raw JSON bounds, agent fingerprints, and scope slugs are
13
+ replaced with a plain-language summary sentence; identifiers survive only on a
14
+ muted "For support" line. Existing grants read as plain English ("Adam Admin can
15
+ deploy to production until 9:37 PM — authorized by you"), and the decision-audit
16
+ table stacks into labelled rows at phone width so the reason column is never
17
+ truncated. Renderer + markup + CSS only — the mandate API, payloads, and
18
+ PIN-never-retained discipline are unchanged. Ships via `dashboard/` static
19
+ serving, so it reaches every agent through the normal update path.
20
+
21
+ 2. **New constitutional standard: Operator-Surface Quality** (sibling to
22
+ Mobile-Complete Operator Actions) in `docs/STANDARDS-REGISTRY.md`. Where
23
+ Mobile-Complete asks "can the operator do this from a phone?", this asks "is it
24
+ actually good when they do?" — lead with the primary action, zero raw internals,
25
+ de-emphasized destructive actions, plain language, phone-width layout. It lands
26
+ with teeth: a new operator-surface-quality question in the instar-dev
27
+ side-effects review template, and a pre-commit gate
28
+ (`scripts/instar-dev-precommit.js`) that blocks any commit touching an operator
29
+ surface unless the review answers it. The standard names that gate, so the
30
+ Standards-Enforcement-Coverage audit classifies it as an enforced gate.
31
+
32
+ ## What to Tell Your User
33
+
34
+ - **A cleaner Mandates screen on your phone**: "I gave the Mandates tab a real
35
+ overhaul. The action you actually came to do — granting someone permission — is
36
+ now front and centre, the risky Revoke button is tucked quietly out of the way,
37
+ and the screen reads in plain English instead of raw data. The history list also
38
+ finally fits your phone without cutting off the reason column."
39
+ - **A new quality bar for everything you touch**: "I also turned this into a
40
+ standing rule for myself: any screen you use to approve or decide something has to
41
+ be genuinely good to use on your phone, not just technically reachable. I added an
42
+ automatic check that holds me to it whenever I build one of those screens."
43
+
44
+ ## Summary of New Capabilities
45
+
46
+ | Capability | How to Use |
47
+ |-----------|-----------|
48
+ | Redesigned Mandates tab (humanized, mobile-first) | Open the Mandates tab in the dashboard |
49
+ | Operator-Surface Quality standard | docs/STANDARDS-REGISTRY.md (constitution) |
50
+ | Operator-surface-quality pre-commit gate | automatic during instar-dev work |
@@ -0,0 +1,200 @@
1
+ # Side-Effects Review — Operator-Surface Quality standard + Mandates-tab redesign
2
+
3
+ **Version / slug:** `operator-surface-quality-and-mandates-redesign`
4
+ **Date:** `2026-06-12`
5
+ **Author:** `Echo (instar dev agent)`
6
+ **Second-pass reviewer:** `not required (Tier 1)`
7
+
8
+ ## Summary of the change
9
+
10
+ Two cohesive changes from one operator finding (2026-06-12, topic 22367, CMT-1434):
11
+ the freshly-shipped Mandates grant form was mobile-*reachable* (per Mobile-Complete
12
+ Operator Actions) yet unusable ("abysmal"). This (A) redesigns the Mandates
13
+ dashboard card to be genuinely good, and (B) lands a new constitutional standard,
14
+ **Operator-Surface Quality**, with a real enforcement gate so reachable-but-bad
15
+ cannot ship again.
16
+
17
+ Files touched:
18
+ - `dashboard/mandates.js` — renderers: grant form is now the card's primary,
19
+ always-open block (was wrapped in a collapsed `<details>`); revoke demoted to a
20
+ quiet collapsed control ordered after grant; JSON bounds / fingerprints / scope
21
+ slugs replaced by a plain-language summary, with ids kept only on a muted "For
22
+ support" line; grants list humanized; audit cells carry `data-label` for
23
+ mobile stacking.
24
+ - `dashboard/index.html` — shortened the explanatory wall to one line + a
25
+ collapsible "What is this?"; added CSS for the primary grant block, the demoted
26
+ revoke, the plain summary, and a `@media (max-width:640px)` rule that stacks the
27
+ audit table so the reason column is never truncated.
28
+ - `docs/STANDARDS-REGISTRY.md` — new "Operator-Surface Quality" article (Interaction
29
+ family), naming `scripts/instar-dev-precommit.js` as its gate.
30
+ - `skills/instar-dev/templates/side-effects-artifact.md` — new §6b operator-surface
31
+ quality question.
32
+ - `scripts/instar-dev-precommit.js` + `scripts/lib/operator-surface.mjs` — the gate
33
+ (`assertOperatorSurfaceQuality`) + its pure decision predicates.
34
+ - Tests: `tests/unit/dashboard-mandateGrantForm.test.ts`,
35
+ `tests/unit/dashboard-mandatesTab.test.ts`,
36
+ `tests/unit/standards-enforcement-auditor.test.ts`,
37
+ `tests/unit/operator-surface-gate.test.ts`.
38
+
39
+ ## Decision-point inventory
40
+
41
+ - `assertOperatorSurfaceQuality` (scripts/instar-dev-precommit.js) — **add** — a new
42
+ scoped review gate: when a commit touches an operator surface, the side-effects
43
+ artifact must answer the operator-surface-quality question or the commit blocks.
44
+ - `isOperatorSurfaceFile` / `artifactAddressesOperatorSurfaceQuality`
45
+ (scripts/lib/operator-surface.mjs) — **add** — the gate's two pure predicates.
46
+ - Mandates renderers (dashboard/mandates.js) — **modify** — presentation only; no
47
+ change to the grant/revoke/issue API calls, payloads, or PIN discipline.
48
+
49
+ ---
50
+
51
+ ## 1. Over-block
52
+
53
+ **What legitimate inputs does this change reject that it shouldn't?**
54
+
55
+ The only block/allow surface added is the pre-commit gate. It fires ONLY when a
56
+ staged file matches an operator surface (`dashboard/*.js|html`, or an
57
+ approval/secret-drop/operator-approval form) AND the side-effects artifact lacks
58
+ the operator-surface-quality phrase. A legitimate operator-surface change whose
59
+ review genuinely answers §6b passes. A change touching no operator surface is never
60
+ evaluated. The detector explicitly excludes `*.test.*`/`*.spec.*` siblings so a
61
+ test guarding a surface isn't itself treated as the surface.
62
+
63
+ ## 2. Under-block
64
+
65
+ **What failure modes does this still miss?**
66
+
67
+ A commit that touches an operator surface but stages NO in-scope file (src/,
68
+ scripts/, .husky/, skill) never reaches the gate at all — the precommit only runs
69
+ its body when an in-scope file is staged. This is the SAME boundary the sibling
70
+ `assertFrameworkGenerality` gate has (it fires only when its src surface is
71
+ touched). A pure dashboard-only commit (dashboard/ is not in-scope) would not be
72
+ gated. Accepted for v1: the dashboard surface is authored by instar-dev work that
73
+ near-always co-stages an in-scope file, and the standard + template question still
74
+ guide the review. Widening the gate's trigger to operator surfaces directly is a
75
+ tracked follow-up, not silently dropped.
76
+
77
+ The gate also checks for *presence* of the §6b engagement, not the substantive
78
+ quality of the four answers — that judgment stays with the reviewer (Signal vs.
79
+ Authority: the gate signals the question must be present; the mind answers it).
80
+
81
+ ## 3. Level-of-abstraction fit
82
+
83
+ **Is this at the right layer?**
84
+
85
+ Yes. The gate is a brittle path/text detector that ENFORCES a written answer; it
86
+ holds no semantic judgment about whether the UI is actually good — that is the
87
+ reviewer's call. This is the correct split: the detector ensures the question is
88
+ asked; the full-context mind answers it. It feeds the existing side-effects review
89
+ flow rather than running parallel to it, mirroring `assertFrameworkGenerality`.
90
+
91
+ ## 4. Signal vs authority compliance
92
+
93
+ **Required reference:** docs/signal-vs-authority.md
94
+
95
+ - [x] No — this change has no block/allow surface that makes a *semantic* judgment.
96
+ The gate's only authority is "the operator-surface-quality section must be present
97
+ in the artifact for an operator-surface change" — a structural presence check, not
98
+ a content-quality verdict. The quality verdict stays with the human/agent reviewer.
99
+
100
+ ## 5. Interactions
101
+
102
+ - **Shadowing:** the new gate runs alongside `assertFrameworkGenerality` at both the
103
+ Tier-1 and Tier-2 pass sites; neither shadows the other (different surfaces,
104
+ different artifacts checks). Both run before `process.exit(0)`.
105
+ - **Double-fire:** the gate is invoked once per pass path; only one pass path
106
+ executes per commit (Tier-1 lite exits, Tier-2 falls through). No double-fire.
107
+ - **Races:** none — pure synchronous file reads at commit time.
108
+ - **Feedback loops:** none.
109
+
110
+ The dashboard renderer changes are pure presentation: the grant/revoke/issue
111
+ controller, fetch calls, payloads, and PIN-never-retained discipline are untouched
112
+ (verified — all existing controller tests pass unchanged).
113
+
114
+ ## 6. External surfaces
115
+
116
+ - Other agents on the same machine? No.
117
+ - Other users of the install base? Yes — the Mandates dashboard tab is the visible
118
+ change. It ships via the package's `dashboard/` (served by `express.static`), so
119
+ it reaches every agent through the normal release → auto-update path. No
120
+ agent-installed-file rewrite is involved for the dashboard.
121
+ - External systems? No.
122
+ - Persistent state? No.
123
+ - **Operator surface (Mobile-Complete Operator Actions):** the Mandates grant/revoke
124
+ actions already have a phone-completable surface (this very tab). This change
125
+ improves that surface; it adds no new API-only operator action.
126
+
127
+ **Migration parity (the one agent-installed file I touched —
128
+ `skills/instar-dev/templates/side-effects-artifact.md`):** this template is deployed
129
+ into dev-agent homes and is already migrated by
130
+ `migrateMultiMachinePostureReviewDimension`. I deliberately did NOT add a new
131
+ `PostUpdateMigrator` migration, and this is a recorded decision, not a silent skip:
132
+ (1) the BINDING enforcement is the pre-commit gate in `scripts/`, which is
133
+ instar-source-repo tooling run from `.husky` — it is NOT a deployed runtime file and
134
+ works for ALL instar-dev work regardless of any installed template copy; (2) the gate
135
+ is self-teaching — its block message names the exact §6b section to add, so no
136
+ developer is left in a silent-fail state (the Migration Parity standard's actual
137
+ concern: "a feature that only works for new agents"); (3) new agents get §6b via
138
+ `installBuiltinSkills`; existing agents NOT yet on the multi-machine template get it
139
+ automatically because the bundled file the existing migration re-copies now contains
140
+ §6b; only an already-multi-machine dev agent misses the template PROMPT, and the gate
141
+ covers it. Touching `PostUpdateMigrator` (fleet machinery the codebase declares
142
+ "never Tier-1") to ship a convenience prompt to that narrow residual is
143
+ disproportionate. Tracked: CMT-1434.
144
+
145
+ ## 7. Multi-machine posture (Cross-Machine Coherence)
146
+
147
+ **machine-local BY DESIGN.** The change is a stateless dashboard renderer + a
148
+ commit-time repo gate. The dashboard reads pool-wide mandate/audit state from the
149
+ server it is served by (no new per-machine state); the gate runs in whichever
150
+ checkout the developer commits from. It emits no user-facing notices, holds no
151
+ durable state, and generates no URLs — so there is nothing to replicate, proxy, or
152
+ strand on a topic transfer. Framework generality: not applicable — this touches
153
+ neither the session launch/inject abstraction nor messaging delivery.
154
+
155
+ ## 8. Rollback cost
156
+
157
+ Pure code change. Revert the renderer/markup/CSS and the gate; ship as a patch. No
158
+ persistent state, no data migration, no agent-state repair. During the rollback
159
+ window users would simply see the prior Mandates card again — no functional
160
+ regression (the underlying mandate API is unchanged throughout).
161
+
162
+ ## Conclusion
163
+
164
+ The review produced one design refinement: extracting the gate's decision logic into
165
+ a pure, unit-tested lib (`scripts/lib/operator-surface.mjs`) rather than inlining
166
+ regexes in the precommit, so both sides of each boundary are pinned by tests and the
167
+ gate's wiring is verifiable. The recorded migration-parity decision (no new
168
+ `PostUpdateMigrator` migration, with explicit reasoning) is the one item flagged for
169
+ the operator's awareness. Clear to ship as a Tier-1 change.
170
+
171
+ ## Operator-surface quality (Operator-Surface Quality standard) — §6b self-review
172
+
173
+ This change IS an operator surface (`dashboard/mandates.js`, `dashboard/index.html`),
174
+ so it is held to the standard it introduces:
175
+
176
+ 1. **Leads with the primary action?** Yes. The Grant form renders as an open,
177
+ titled `mnd-grant-block` — never inside a collapsed `<details>`. On a mandate
178
+ with no grants it is the visible call to action.
179
+ 2. **Zero raw internals as primary content?** Yes. The card headline is a
180
+ de-slugified scope title; the body is a plain-language summary. No JSON bounds,
181
+ no fingerprints, no slugs as primary content (asserted: the card body contains no
182
+ `{"` substring and no raw bounds keys). Ids/fingerprints/slugs survive only on a
183
+ muted "For support" line.
184
+ 3. **Destructive actions de-emphasized?** Yes. Revoke is a quiet, collapsed
185
+ `mnd-revoke-details` ordered AFTER the Grant block in the DOM; the danger button
186
+ is no longer bold/featured (asserted: grant index < revoke index).
187
+ 4. **Plain language + phone width?** Yes. Grants read "Adam Admin can deploy to
188
+ production until … — authorized by you"; action slugs are humanized; the audit
189
+ table stacks into labelled rows at ≤640px so the reason column is never
190
+ truncated.
191
+
192
+ ## Evidence pointers
193
+
194
+ - `tests/unit/dashboard-mandateGrantForm.test.ts` — grant form NOT in collapsed
195
+ `<details>`; no `{"` JSON in card body; revoke ordered after grant; humanized
196
+ grants; mobile audit labels.
197
+ - `tests/unit/operator-surface-gate.test.ts` — both predicates (both sides of each
198
+ boundary) + wiring-integrity (the gate is called on both pass paths, not a no-op).
199
+ - `tests/unit/standards-enforcement-auditor.test.ts` — the new standard classifies as
200
+ `gate` with zero dangling refs over the real registry.