instar 1.3.312 → 1.3.314

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.
@@ -808,11 +808,16 @@
808
808
  .mnd-btn-danger { color: #f85149; }
809
809
  .mnd-dead-note { font-size: 13px; color: var(--text-dim); font-style: italic; }
810
810
  .mnd-notice { min-height: 20px; font-size: 14px; color: #3fb950; }
811
- .mnd-notice-err { color: #f85149; }
812
- .mnd-issue-form { display: flex; flex-direction: column; gap: 10px; max-width: 640px; padding: 12px 0; }
813
- .mnd-issue-form label { display: flex; flex-direction: column; gap: 4px; font-size: 13px; color: var(--text-dim); }
814
- .mnd-issue-form input, .mnd-issue-form textarea { font: inherit; padding: 6px 8px; }
815
- .mnd-issue-form textarea { font-family: ui-monospace, monospace; font-size: 12px; }
811
+ .mnd-issue-form { display: flex; flex-direction: column; gap: 14px; max-width: 640px; padding: 12px 0; }
812
+ .mnd-issue-form label { display: flex; flex-direction: column; gap: 4px; font-size: 15px; color: var(--text-bright); }
813
+ .mnd-issue-form input, .mnd-issue-form textarea { font: inherit; padding: 8px 10px; border-radius: 6px; }
814
+ .mnd-issue-form textarea { font-family: ui-monospace, monospace; font-size: 13px; }
815
+ .mnd-hint { font-size: 13px; color: var(--text-dim); font-weight: 400; line-height: 1.45; }
816
+ .mnd-prefilled { font-size: 12px; color: var(--ph-accent, #4ade80); }
817
+ /* Issue-form feedback: PERSISTENT, not a transient toast. A failed issuance
818
+ the operator never sees (the 2026-06-05 incident) is worse than no form. */
819
+ .mnd-notice-err { border: 1px solid #b91c1c; background: rgba(185, 28, 28, 0.12); color: #fca5a5; padding: 12px 14px; border-radius: 8px; font-size: 15px; line-height: 1.5; white-space: pre-line; }
820
+ .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; }
816
821
  .mnd-table { width: 100%; border-collapse: collapse; font-size: 13px; }
817
822
  .mnd-table th, .mnd-table td { text-align: left; padding: 6px 8px; border-bottom: 1px solid var(--border); vertical-align: top; }
818
823
  .mnd-reason-cell { color: var(--text-dim); max-width: 420px; }
@@ -826,7 +831,13 @@
826
831
  maturation "you're here" marker. */
827
832
  .ph-root {
828
833
  --ph-accent: #4ade80;
834
+ /* These panels are declared after </main>, making them direct .app grid
835
+ children. switchTab() hides the sidebar too, so without explicit
836
+ placement the lone visible panel auto-places into the 280px sidebar
837
+ COLUMN — the "squashed to the side" bug. Span the full grid. */
838
+ grid-column: 1 / -1;
829
839
  max-width: 760px;
840
+ width: 100%;
830
841
  font-size: 18px;
831
842
  line-height: 1.55;
832
843
  color: var(--text-bright);
@@ -3242,15 +3253,34 @@
3242
3253
  <h3 class="ph-h">Issued mandates</h3>
3243
3254
  <div id="mndList"></div>
3244
3255
  </section>
3245
- <details class="ph-detail">
3256
+ <details class="ph-detail" id="mndIssueDetails">
3246
3257
  <summary class="ph-detail-summary">Issue a new mandate (PIN required)</summary>
3247
3258
  <div class="mnd-issue-form">
3248
- <label>Scope <input type="text" id="mndIssueScope" value="feedback-migration" /></label>
3249
- <label>Agent A fingerprint <input type="text" id="mndIssueAgentA" placeholder="full routing fingerprint" /></label>
3250
- <label>Agent B fingerprint <input type="text" id="mndIssueAgentB" placeholder="full routing fingerprint" /></label>
3251
- <label>Authorities (JSON) <textarea id="mndIssueAuthorities" rows="9" spellcheck="false"></textarea></label>
3252
- <label>Expires <input type="datetime-local" id="mndIssueExpires" /></label>
3253
- <label>Your dashboard PIN <input type="password" id="mndIssuePin" autocomplete="off" placeholder="typed at issuance, never stored" /></label>
3259
+ <label>What is this permission slip for?
3260
+ <span class="mnd-hint">A short name for the project the two agents may work on together, e.g. feedback-migration.</span>
3261
+ <input type="text" id="mndIssueScope" value="feedback-migration" />
3262
+ </label>
3263
+ <label>This agent (Agent A)
3264
+ <span class="mnd-hint">Pre-filled with this agent's own routing fingerprint you normally don't need to touch it.</span>
3265
+ <input type="text" id="mndIssueAgentA" placeholder="filling in automatically…" />
3266
+ <span class="mnd-prefilled" id="mndAgentAPrefillNote"></span>
3267
+ </label>
3268
+ <label>The other agent (Agent B)
3269
+ <span class="mnd-hint">The peer agent's routing fingerprint — find it on that agent's Threadline tab, or ask the agent "what's your routing fingerprint?".</span>
3270
+ <input type="text" id="mndIssueAgentB" placeholder="e.g. 8c7928aa9f04fbda947172a2f9b2d81a" />
3271
+ </label>
3272
+ <label>What exactly may they do? (the authorities)
3273
+ <span class="mnd-hint">Each entry is one delegated action with its bounds. The template below covers the standard credential-handoff + code-review pair — edit only if you know you need different bounds.</span>
3274
+ <textarea id="mndIssueAuthorities" rows="9" spellcheck="false"></textarea>
3275
+ </label>
3276
+ <label>When does it expire?
3277
+ <span class="mnd-hint">Pre-filled to one week from now. Every mandate expires; expired means denied.</span>
3278
+ <input type="datetime-local" id="mndIssueExpires" />
3279
+ </label>
3280
+ <label>Your dashboard PIN
3281
+ <span class="mnd-hint">Issuing is a human action — only your PIN can do it. Sent once, never stored.</span>
3282
+ <input type="password" id="mndIssuePin" autocomplete="off" placeholder="typed at issuance, never stored" />
3283
+ </label>
3254
3284
  <button id="mndIssueBtn" class="mnd-btn">Issue mandate</button>
3255
3285
  </div>
3256
3286
  </details>
@@ -8565,6 +8595,8 @@
8565
8595
  issueExpires: document.getElementById('mndIssueExpires'),
8566
8596
  issuePin: document.getElementById('mndIssuePin'),
8567
8597
  issueBtn: document.getElementById('mndIssueBtn'),
8598
+ issueDetails: document.getElementById('mndIssueDetails'),
8599
+ agentAPrefillNote: document.getElementById('mndAgentAPrefillNote'),
8568
8600
  };
8569
8601
  const fetchImpl = (url, opts = {}) => fetch(url, {
8570
8602
  ...opts,
@@ -106,13 +106,30 @@ export function createController({ doc, els, fetchImpl }) {
106
106
  return { status: res.status, body };
107
107
  }
108
108
 
109
- function note(msg, isError) {
109
+ // kind: 'error' | 'success' are PERSISTENT (cleared only by the next note or
110
+ // an explicit clearNote) — a transient toast the operator never sees is how
111
+ // the 2026-06-05 silent-issuance-failure happened. 'info' stays transient.
112
+ function note(msg, kind) {
110
113
  if (!els.notice) return;
114
+ const isError = kind === true || kind === 'error'; // boolean kept for back-compat
111
115
  els.notice.textContent = msg;
112
- els.notice.className = isError ? 'mnd-notice mnd-notice-err' : 'mnd-notice';
113
- if (msg) setTimeout(() => { if (els.notice.textContent === msg) els.notice.textContent = ''; }, 8000);
116
+ els.notice.className = isError ? 'mnd-notice mnd-notice-err'
117
+ : kind === 'success' ? 'mnd-notice mnd-notice-ok'
118
+ : 'mnd-notice';
119
+ if (msg && !isError && kind !== 'success') {
120
+ setTimeout(() => { if (els.notice.textContent === msg) els.notice.textContent = ''; }, 8000);
121
+ }
114
122
  }
115
123
 
124
+ function clearNote() {
125
+ if (!els.notice) return;
126
+ els.notice.textContent = '';
127
+ els.notice.className = 'mnd-notice';
128
+ }
129
+
130
+ let refreshErrorShown = false;
131
+ let autoOpenedIssueForm = false;
132
+
116
133
  async function refresh() {
117
134
  try {
118
135
  const [mand, audit] = await Promise.all([
@@ -125,12 +142,22 @@ export function createController({ doc, els, fetchImpl }) {
125
142
  if (els.stamp) els.stamp.textContent = '';
126
143
  return;
127
144
  }
128
- els.list.innerHTML = renderMandates(mand.body?.mandates);
145
+ const mandates = mand.body?.mandates;
146
+ els.list.innerHTML = renderMandates(mandates);
129
147
  els.audit.innerHTML = renderAudit(audit.body);
130
148
  if (els.stamp) els.stamp.textContent = 'updated ' + new Date().toLocaleTimeString();
149
+ // Nothing issued yet → the issue form IS the page's call to action;
150
+ // open it once rather than hiding it behind a collapsed <details>.
151
+ if (!autoOpenedIssueForm && els.issueDetails && (!Array.isArray(mandates) || mandates.length === 0)) {
152
+ els.issueDetails.open = true;
153
+ autoOpenedIssueForm = true;
154
+ }
155
+ // A persistent refresh error from a server restart-gap heals itself.
156
+ if (refreshErrorShown) { clearNote(); refreshErrorShown = false; }
131
157
  wireRevokeButtons();
132
158
  } catch (e) {
133
- note('refresh failed: ' + (e?.message ?? e), true);
159
+ refreshErrorShown = true;
160
+ note('refresh failed: ' + (e?.message ?? e) + ' — retrying automatically.', 'error');
134
161
  }
135
162
  }
136
163
 
@@ -150,25 +177,54 @@ export function createController({ doc, els, fetchImpl }) {
150
177
  body: JSON.stringify({ pin, reason: reasonEl?.value || 'operator revocation (dashboard)' }),
151
178
  });
152
179
  if (pinEl) pinEl.value = ''; // never retain the PIN
153
- if (status === 200) { note(`Mandate ${id} revoked.`); await refresh(); }
154
- else note(body?.error ?? `revoke failed (${status})`, true);
180
+ if (status === 200) { note(`✓ Mandate ${id} revoked — the gate now denies its actions.`, 'success'); await refresh(); }
181
+ else note(`Not revoked — the server refused: ${body?.error ?? `HTTP ${status}`}`, 'error');
155
182
  } finally { btn.disabled = false; }
156
183
  };
157
184
  });
158
185
  }
159
186
 
187
+ // Validate EVERYTHING client-side and report every problem at once —
188
+ // never POST a request we know the server will refuse, and never let the
189
+ // operator discover a missing field one transient error at a time.
190
+ function validateIssueForm() {
191
+ const problems = [];
192
+ if (!els.issueScope?.value?.trim()) problems.push('• Scope is empty — give the permission slip a short name.');
193
+ if (!els.issueAgentA?.value?.trim()) problems.push('• Agent A is empty — this agent’s own fingerprint (normally pre-filled).');
194
+ if (!els.issueAgentB?.value?.trim()) problems.push('• Agent B is empty — paste the other agent’s routing fingerprint.');
195
+ let authorities = null;
196
+ const rawAuth = els.issueAuthorities?.value ?? '';
197
+ try {
198
+ authorities = JSON.parse(rawAuth || '[]');
199
+ if (!Array.isArray(authorities) || authorities.length === 0) {
200
+ problems.push('• Authorities is empty — the mandate must delegate at least one action.');
201
+ authorities = null;
202
+ }
203
+ } catch {
204
+ problems.push('• Authorities is not valid JSON — it must be an array of { action, bounds }.');
205
+ }
206
+ const expiresRaw = els.issueExpires?.value ?? '';
207
+ if (!expiresRaw) {
208
+ problems.push('• Expiry is empty — every mandate must expire (normally pre-filled to a week out).');
209
+ } else if (!(Date.parse(expiresRaw) > Date.now())) {
210
+ problems.push('• Expiry is in the past — pick a future date.');
211
+ }
212
+ if (!els.issuePin?.value) problems.push('• Your dashboard PIN is missing — issuing is a human action; agent credentials are refused.');
213
+ return { problems, authorities };
214
+ }
215
+
160
216
  async function issue() {
161
- const pin = els.issuePin?.value ?? '';
162
- if (!pin) { note('Type your dashboard PIN — issuance is a human action; agent credentials are refused.', true); return; }
163
- let authorities;
164
- try { authorities = JSON.parse(els.issueAuthorities?.value || '[]'); }
165
- catch { note('Authorities must be valid JSON (array of { action, bounds }).', true); return; }
217
+ const { problems, authorities } = validateIssueForm();
218
+ if (problems.length > 0) {
219
+ note('Not issued — fix the following first:\n' + problems.join('\n'), 'error');
220
+ return;
221
+ }
166
222
  const payload = {
167
- pin,
223
+ pin: els.issuePin.value,
168
224
  scope: els.issueScope?.value?.trim(),
169
225
  agents: [els.issueAgentA?.value?.trim(), els.issueAgentB?.value?.trim()],
170
226
  authorities,
171
- expiresAt: els.issueExpires?.value ? new Date(els.issueExpires.value).toISOString() : '',
227
+ expiresAt: new Date(els.issueExpires.value).toISOString(),
172
228
  };
173
229
  els.issueBtn.disabled = true;
174
230
  try {
@@ -179,19 +235,51 @@ export function createController({ doc, els, fetchImpl }) {
179
235
  });
180
236
  if (els.issuePin) els.issuePin.value = ''; // never retain the PIN
181
237
  if (status === 201) {
182
- note(`Mandate ${body?.mandate?.id ?? ''} issued.`);
238
+ note(`✓ Mandate ${body?.mandate?.id ?? ''} issued — it is active and listed above. The agents can now act within its bounds.`, 'success');
239
+ if (els.issueDetails) els.issueDetails.open = false;
183
240
  await refresh();
184
241
  } else {
185
- note(body?.error ?? `issue failed (${status})`, true);
242
+ note(`Not issued — the server refused: ${body?.error ?? `HTTP ${status}`}`, 'error');
186
243
  }
187
244
  } finally { els.issueBtn.disabled = false; }
188
245
  }
189
246
 
247
+ // datetime-local wants local "YYYY-MM-DDTHH:MM" — toISOString() (UTC, with
248
+ // seconds + Z) renders as blank in the picker.
249
+ function defaultExpiryLocal() {
250
+ const d = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000);
251
+ const pad = (n) => String(n).padStart(2, '0');
252
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
253
+ }
254
+
255
+ // The system KNOWS this agent's fingerprint — asking the operator to paste a
256
+ // 32-hex string the dashboard can fetch itself is exactly the UX failure
257
+ // Justin flagged. /threadline/health reads the canonical identity.json (works
258
+ // even when the relay is disconnected); /threadline/status is the fallback.
259
+ async function prefillAgentA() {
260
+ if (!els.issueAgentA || els.issueAgentA.value) return;
261
+ try {
262
+ let fp = null;
263
+ const health = await fetchJson('/threadline/health');
264
+ if (health.status === 200) fp = health.body?.fingerprint ?? null;
265
+ if (!fp) {
266
+ const status = await fetchJson('/threadline/status');
267
+ fp = status.body?.relay?.fingerprint ?? null;
268
+ }
269
+ if (fp && els.issueAgentA && !els.issueAgentA.value) {
270
+ els.issueAgentA.value = fp;
271
+ if (els.agentAPrefillNote) els.agentAPrefillNote.textContent = '✓ pre-filled — this agent’s own fingerprint';
272
+ }
273
+ } catch { /* leave blank; validation reports it plainly */ }
274
+ }
275
+
190
276
  function start() {
191
277
  if (running) return;
192
278
  running = true;
193
279
  if (els.issueAuthorities && !els.issueAuthorities.value) els.issueAuthorities.value = AUTHORITIES_TEMPLATE;
280
+ if (els.issueExpires && !els.issueExpires.value) els.issueExpires.value = defaultExpiryLocal();
194
281
  if (els.issueBtn) els.issueBtn.onclick = issue;
282
+ prefillAgentA();
195
283
  refresh();
196
284
  timer = setInterval(refresh, REFRESH_MS);
197
285
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "instar",
3
- "version": "1.3.312",
3
+ "version": "1.3.314",
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",
@@ -278,6 +278,41 @@ try {
278
278
  const slug = (freshestTrace && (freshestTrace.slug || freshestTrace.name)) || 'unknown';
279
279
  const decision = decideRequirementSet(declaredTier);
280
280
 
281
+ // ─── Step 4.55: causal autopsy (directive 2026-06-05) ───────────────────
282
+ // Low-ceremony lanes (Tier-1) ship without an independent reviewer, so the
283
+ // compensating control is a durable causal record per issue: every fix-class
284
+ // commit SHOULD declare what caused the issue it fixes — a prior PR, an
285
+ // environment shift that invalidated old assumptions, plain new code, a
286
+ // latent bug, or honestly unknown. The field rides the decision audit so
287
+ // meta-analysis ("are we converging or playing whack-a-mole?") is a query
288
+ // over .instar/instar-dev-decisions/, not archaeology. ADVISORY in this
289
+ // slice: absence warns on fix-class signals, never blocks. A PRESENT but
290
+ // malformed autopsy blocks — a corrupt record is worse than none.
291
+ const AUTOPSY_ORIGINS = ['prior-pr', 'environment-shift', 'new-code', 'latent', 'unknown'];
292
+ let causalAutopsy = null;
293
+ let autopsyError = null;
294
+ if (freshestTrace && freshestTrace.causalAutopsy !== undefined) {
295
+ const ca = freshestTrace.causalAutopsy;
296
+ const validPrs = (a) => Array.isArray(a) && a.length > 0 && a.every((n) => Number.isInteger(n) && n > 0);
297
+ if (!ca || typeof ca !== 'object' || Array.isArray(ca)) {
298
+ autopsyError = 'causalAutopsy must be an object: { origin, relatedPrs?, notes? }';
299
+ } else if (!AUTOPSY_ORIGINS.includes(ca.origin)) {
300
+ autopsyError = `causalAutopsy.origin must be one of ${AUTOPSY_ORIGINS.join(' | ')} (got ${JSON.stringify(ca.origin)})`;
301
+ } else if (ca.origin === 'prior-pr' && !validPrs(ca.relatedPrs)) {
302
+ autopsyError = 'causalAutopsy.origin "prior-pr" requires relatedPrs: a non-empty array of positive PR numbers';
303
+ } else if (ca.relatedPrs !== undefined && !validPrs(ca.relatedPrs)) {
304
+ autopsyError = 'causalAutopsy.relatedPrs must be a non-empty array of positive integers when present';
305
+ } else if (ca.notes !== undefined && typeof ca.notes !== 'string') {
306
+ autopsyError = 'causalAutopsy.notes must be a string when present';
307
+ } else {
308
+ causalAutopsy = {
309
+ origin: ca.origin,
310
+ ...(ca.relatedPrs !== undefined ? { relatedPrs: ca.relatedPrs } : {}),
311
+ ...(ca.notes !== undefined ? { notes: ca.notes } : {}),
312
+ };
313
+ }
314
+ }
315
+
281
316
  // AUDIT (all in-scope cases): one JSON line, written regardless of branch.
282
317
  // belowFloor = the agent declared UNDER the risk-signaled floor. We never
283
318
  // block on it (the mind holds authority) — the record is the backstop.
@@ -291,7 +326,54 @@ const decisionEntryPath = writeDecisionAudit({
291
326
  belowFloor,
292
327
  files: inScopeFiles.length,
293
328
  loc: totalChangedLoc,
329
+ causalAutopsy,
294
330
  });
331
+ // Malformed autopsy blocks AFTER the audit write — the blocked attempt is
332
+ // recorded (verdict 'blocked' via the exit handler), same as every gate
333
+ // refusal. Validated-when-present: absence never reaches this.
334
+ if (autopsyError) {
335
+ blockCommit(
336
+ inScopeFiles,
337
+ `Invalid causalAutopsy in trace: ${autopsyError}\n` +
338
+ ` Shape: { "origin": "prior-pr|environment-shift|new-code|latent|unknown", "relatedPrs": [123], "notes": "..." }\n` +
339
+ ` (origin "prior-pr" requires relatedPrs; the field is otherwise optional-but-validated.)`,
340
+ );
341
+ }
342
+ // Advisory (never blocks): a fix-class commit with NO autopsy gets a loud
343
+ // nudge. Fix-class signal = branch name says fix, or a staged release-note
344
+ // fragment declares change_type: fix.
345
+ if (!causalAutopsy) {
346
+ let fixClassSignal = false;
347
+ try {
348
+ const branch = execSync('git rev-parse --abbrev-ref HEAD', { cwd: ROOT, encoding: 'utf8' }).trim();
349
+ if (/(^|[\/-])fix([\/-]|$)/i.test(branch)) fixClassSignal = true;
350
+ } catch { /* detached HEAD / no commits — stay quiet, advisory only */ }
351
+ if (!fixClassSignal) {
352
+ try {
353
+ const staged = execSync('git diff --cached --name-only', { cwd: ROOT, encoding: 'utf8' })
354
+ .split('\n').map((s) => s.trim()).filter(Boolean);
355
+ for (const f of staged) {
356
+ if (!/^upgrades\/next\/.+\.md$/.test(f)) continue;
357
+ const fp = path.join(ROOT, f);
358
+ if (fs.existsSync(fp) && /change_type:\s*fix/.test(fs.readFileSync(fp, 'utf8'))) {
359
+ fixClassSignal = true;
360
+ break;
361
+ }
362
+ }
363
+ } catch { /* advisory only */ }
364
+ }
365
+ if (fixClassSignal) {
366
+ console.error('');
367
+ console.error('┌──────────────────────────────────────────────────────────────────┐');
368
+ console.error('│ ⚠ ADVISORY — fix-class commit with no causalAutopsy in trace. │');
369
+ console.error('│ What caused the issue this fixes? Add to your trace JSON: │');
370
+ console.error('│ "causalAutopsy": { "origin": "prior-pr|environment-shift| │');
371
+ console.error('│ new-code|latent|unknown", "relatedPrs": [N], "notes": "…" } │');
372
+ console.error('│ NOT blocked — but the meta-analysis record stays blind here. │');
373
+ console.error('└──────────────────────────────────────────────────────────────────┘');
374
+ console.error('');
375
+ }
376
+ }
295
377
  if (belowFloor) {
296
378
  console.error('');
297
379
  console.error('┌──────────────────────────────────────────────────────────────────┐');
@@ -814,7 +896,7 @@ function blockCommit(files, reason) {
814
896
  // fire, the line just evaporated with the worktree). If the commit is later
815
897
  // blocked by the gate, the staged line simply rides the retry commit — both
816
898
  // lines describe real gate evaluations.
817
- function writeDecisionAudit({ slug, suggestedTier, declaredTier, riskFloor, riskFloorReasons, belowFloor, files, loc }) {
899
+ function writeDecisionAudit({ slug, suggestedTier, declaredTier, riskFloor, riskFloorReasons, belowFloor, files, loc, causalAutopsy = null }) {
818
900
  try {
819
901
  fs.mkdirSync(DECISIONS_DIR, { recursive: true });
820
902
  const ts = new Date().toISOString();
@@ -840,6 +922,12 @@ function writeDecisionAudit({ slug, suggestedTier, declaredTier, riskFloor, risk
840
922
  belowFloor,
841
923
  files,
842
924
  loc,
925
+ // Causal autopsy (directive 2026-06-05): what caused the issue this
926
+ // commit fixes — prior-pr / environment-shift / new-code / latent /
927
+ // unknown, with linked PRs. null = not declared (advisory in slice 1).
928
+ // This is THE meta-analysis substrate: convergence vs whack-a-mole is
929
+ // a query over these entries, not archaeology.
930
+ causalAutopsy,
843
931
  // Finalized by the process exit handler below: 'pass' when the gate
844
932
  // allowed the commit, 'blocked' otherwise. The riding-the-retry design
845
933
  // (a blocked evaluation's entry rides the next successful commit, see
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "$schema": "./builtin-manifest.schema.json",
3
3
  "schemaVersion": 1,
4
- "generatedAt": "2026-06-05T18:04:49.958Z",
5
- "instarVersion": "1.3.312",
4
+ "generatedAt": "2026-06-05T18:49:31.951Z",
5
+ "instarVersion": "1.3.314",
6
6
  "entryCount": 199,
7
7
  "entries": {
8
8
  "hook:session-start": {
@@ -0,0 +1,21 @@
1
+ # Upgrade Guide — vNEXT
2
+
3
+ <!-- assembled-by: assemble-next-md -->
4
+ <!-- bump: patch -->
5
+
6
+ ## What Changed
7
+
8
+ The instar-dev pre-commit gate accepts an optional `causalAutopsy` field in the trace JSON — { origin: prior-pr | environment-shift | new-code | latent | unknown, relatedPrs, notes } — validates it when present (malformed blocks; absence never does), copies it verbatim into the per-commit decision-audit entry, and prints an advisory nudge on fix-class commits that omit it.
9
+
10
+ ## What to Tell Your User
11
+
12
+ Every fix can now carry a durable record of what actually caused the issue it fixes. Over time that record shows whether the system is genuinely converging toward stability or just trading one bug for another — analysis that used to require digging through old conversations.
13
+
14
+ ## Summary of New Capabilities
15
+
16
+ - Causal autopsy per fix, riding the existing decision-audit files — meta-analysis over causes becomes a one-line query.
17
+ - Advisory-first rollout: fix-class commits without an autopsy get a loud nudge, never a block.
18
+
19
+ ## Evidence
20
+
21
+ Directive from Justin (2026-06-05): with the Tier-1 lane shipping fixes without an independent reviewer, causal autopsies of every issue are the compensating control for convergence. Day-one retro-autopsy of 6 issues found 5 were prior-PR assumptions invalidated by the release-cadence shift — a pattern that currently lives only in session memory. 5 new gate tests pin the contract (valid recorded verbatim / malformed blocks with attempt recorded / prior-pr requires linked PRs / absent never blocks + advisory on fix signal / no noise on non-fix commits); 9/9 in the gate audit suite.
@@ -0,0 +1,89 @@
1
+ # Upgrade Guide — vNEXT
2
+
3
+ <!-- assembled-by: assemble-next-md -->
4
+ <!-- bump: patch -->
5
+
6
+ ## What Changed
7
+
8
+ The Mandates dashboard tab was overhauled after a real operator failed to
9
+ issue a mandate through it (2026-06-05). Four fixes:
10
+
11
+ 1. **Layout**: the panel (and the Process Health / Machines / Preferences
12
+ panels, which shared the bug) rendered squashed into the 280px sidebar
13
+ grid column. All `.ph-root` panels now span the full grid.
14
+ 2. **Pre-fills**: Agent A is pre-filled with this agent's own routing
15
+ fingerprint (fetched from `/threadline/health`); expiry is pre-filled to
16
+ one week out. The operator no longer pastes hex strings the system
17
+ already knows.
18
+ 3. **Validation**: clicking Issue with missing/invalid fields now reports
19
+ EVERY problem at once in plain English, client-side, before anything is
20
+ sent. Previously an empty form POSTed, the server refused, and the only
21
+ feedback was a transient toast.
22
+ 4. **Persistent feedback**: errors and the "✓ Mandate issued" confirmation
23
+ are persistent banners, not 8-second toasts. A silent failure is no
24
+ longer possible; success is unmissable.
25
+
26
+ The last knowingly-stale entry from the 2026-06-05 test-debt triage is
27
+ resolved: the TunnelManager unit suite — parked in the CI skip-list because it
28
+ predated the tunnel provider/tier rewrite (22 of its 29 tests could never
29
+ pass) — has been rewritten against the current architecture and turned back
30
+ on. The tunnel layer's core lifecycle is now gated in CI at the manager level:
31
+ provider-pool fallback, the reachability probe, the owner-consent flow for
32
+ backup relays (including the single-use nonce and the decline cooldown), the
33
+ self-heal stability gate, and mandatory credential rotation after a relay
34
+ episode.
35
+
36
+ ## What to Tell Your User
37
+
38
+ If you ever tried to issue a coordination mandate from the dashboard and
39
+ nothing seemed to happen, that flow is fixed: the form now fills in
40
+ everything the system already knows, tells you exactly what is missing
41
+ before submitting, and shows an unmissable confirmation when the mandate is
42
+ issued. The Mandates, Process Health, Machines, and Preferences tabs also
43
+ now use the full width of the page instead of a narrow column.
44
+
45
+ Nothing user-visible. This hardens the safety net around the tunnel/backup-
46
+ relay machinery your dashboard links depend on — regressions in that
47
+ machinery now fail PRs instead of shipping.
48
+
49
+ ## Summary of New Capabilities
50
+
51
+ - Mandates tab: pre-filled Agent A fingerprint + default one-week expiry,
52
+ all-at-once client-side validation, persistent success/error banners,
53
+ plain-English field hints, and the issue form auto-opens when no mandates
54
+ exist yet.
55
+ - Layout fix for all `.ph-root` dashboard panels (Mandates, Process Health,
56
+ Machines, Preferences Learning): full-grid width instead of the sidebar
57
+ column.
58
+ - Maturity: stable (UI-only change over the existing PIN-gated issuance
59
+ API; no server surface changed).
60
+
61
+ - `tests/unit/TunnelManager.test.ts` runs in CI again (removed from the
62
+ vitest.push.config.ts quarantine): 51 deterministic tests driven entirely
63
+ through the constructor injection seams (`injections.providers`,
64
+ `injections.fetch`) and the public deterministic drivers
65
+ (`runSelfHealCheck()`, `grantConsent()`, `declineConsent()`) — no real
66
+ timers, processes, or network.
67
+ - Maturity: stable (test-only change + quarantine re-arm; no production code
68
+ touched).
69
+
70
+ ## Evidence
71
+
72
+ Born from a live operator report (2026-06-05): "I clicked 'Issue Mandate'
73
+ after entering my PIN… this UI/UX is TERRIBLE. Very confusing, unclear, and
74
+ squashed to the side" — the mandate never registered server-side and the
75
+ failure was invisible. 24 tests green in the mandates tab suite (squash
76
+ regression pin, every-problem-at-once validation, persistence under fake
77
+ timers, prefill + never-overwrite, auto-open); 80 green across all dashboard
78
+ suites; tsc clean.
79
+
80
+ Three consecutive green runs (51/51, ~0.8s each), including under the push
81
+ config; the full 11-file tunnel test family passes together (165/165);
82
+ `npx tsc --noEmit` clean. The rewrite-era sibling suites
83
+ (tunnel-manager-rewrite / tunnel-self-heal / tunnel-consent-state-machine /
84
+ tunnel-credential-rotation) already covered parts of this surface — this
85
+ suite consolidates the manager-level coverage, adds the previously-untested
86
+ edge paths (persisted-state restore, corrupted state file, nonce replay,
87
+ cooldown suppression across restart, rotation-failure flag retention,
88
+ probe-handle release), and retires the stale file that occupied the
89
+ canonical TunnelManager.test.ts name.
@@ -0,0 +1,53 @@
1
+ # Side-effects review — TunnelManager unit-suite rewrite + quarantine re-arm
2
+
3
+ Closes the durable commitment "Rewrite TunnelManager unit suite" opened during
4
+ the 2026-06-05 full-suite triage (the one knowingly-stale entry left parked by
5
+ the test-debt re-arm PR #837).
6
+
7
+ ## 1. The change
8
+
9
+ - **`tests/unit/TunnelManager.test.ts`** — full rewrite. The old suite
10
+ predated the tunnel provider/tier rewrite: it mocked the `cloudflared`
11
+ module directly, while production drives a provider pool with a REAL
12
+ reachability probe (`driveTier1` → `probeReachability` fetches
13
+ `<url>/health` and requires 2xx), so 22/29 tests could never pass. The new
14
+ suite uses the constructor injection seams exclusively
15
+ (`injections.providers`, `injections.fetch`) plus the public deterministic
16
+ drivers (`runSelfHealCheck()`, `grantConsent()`, `declineConsent()`):
17
+ 51 tests covering constructor/persisted-state restore, the happy start
18
+ path, provider-pool fallback (unavailable / start-throw / probe-fail /
19
+ fetch-throw), Tier-2-never-auto-started, exhaustion + relaysEnabled /
20
+ relayConsent config gates, the consent flow (nonce issuance, wrong-nonce
21
+ rejection, single-use replay, decline cooldown, timeout, cooldown
22
+ suppression across restart, post-grant probe/start failure), the self-heal
23
+ stability gate (reset / progress / switched, counter reset on flap,
24
+ throwaway-probe release, self-healed event), stop semantics (relay-stop
25
+ rotates credentials, plain stop doesn't, pending consent cleared), and
26
+ credential rotation (no-op when not pending, failure retains the flag +
27
+ emits rotation-failed, boot recovery, unwired-rotator loud-clear).
28
+ - **`vitest.push.config.ts`** — `tests/unit/TunnelManager.test.ts` REMOVED
29
+ from FLAKY_TESTS (re-armed) with a dated comment. No other entries touched.
30
+
31
+ ## 2. Blast radius
32
+
33
+ - Zero production code changed — test + CI-config only.
34
+ - CI runs 51 additional deterministic tests (~0.8s, no network/timers/
35
+ processes). Risk is future-positive: tunnel-lifecycle regressions now fail
36
+ PRs instead of rotting behind the quarantine.
37
+ - Honest overlap note: the rewrite-era sibling suites
38
+ (tunnel-manager-rewrite.test.ts, tunnel-self-heal.test.ts,
39
+ tunnel-consent-state-machine.test.ts, tunnel-credential-rotation.test.ts —
40
+ 39 tests, already in CI) cover parts of this surface. The new suite
41
+ consolidates manager-level coverage under the canonical filename and adds
42
+ edge paths they don't exercise (persisted-state restore + corrupted state
43
+ file, concurrent-start coalescing, start-after-exhaustion semantics, nonce
44
+ replay, cooldown suppression across construction, rotation-failure flag
45
+ retention, probe-handle release). Redundancy between them is cheap and
46
+ deliberate.
47
+
48
+ ## 3. Test coverage
49
+
50
+ - New suite: 51/51 green across three consecutive runs (including under the
51
+ push config — proving the re-arm actually executes it).
52
+ - Full tunnel family (11 files incl. the 4 rewrite-era siblings): 165/165.
53
+ - `npx tsc --noEmit` clean.