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.
- package/dashboard/index.html +44 -12
- package/dashboard/mandates.js +104 -16
- package/package.json +1 -1
- package/scripts/instar-dev-precommit.js +89 -1
- package/src/data/builtin-manifest.json +2 -2
- package/upgrades/1.3.313.md +21 -0
- package/upgrades/1.3.314.md +89 -0
- package/upgrades/side-effects/tunnelmanager-suite-rearm.md +53 -0
package/dashboard/index.html
CHANGED
|
@@ -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-
|
|
812
|
-
.mnd-issue-form { display: flex; flex-direction: column; gap:
|
|
813
|
-
.mnd-issue-form
|
|
814
|
-
.mnd-issue-form
|
|
815
|
-
.mnd-
|
|
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>
|
|
3249
|
-
|
|
3250
|
-
|
|
3251
|
-
|
|
3252
|
-
<label>
|
|
3253
|
-
|
|
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,
|
package/dashboard/mandates.js
CHANGED
|
@@ -106,13 +106,30 @@ export function createController({ doc, els, fetchImpl }) {
|
|
|
106
106
|
return { status: res.status, body };
|
|
107
107
|
}
|
|
108
108
|
|
|
109
|
-
|
|
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'
|
|
113
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
154
|
-
else note(body?.error ?? `
|
|
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
|
|
162
|
-
if (
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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:
|
|
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(
|
|
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 ?? `
|
|
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
|
@@ -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:
|
|
5
|
-
"instarVersion": "1.3.
|
|
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.
|