instar 1.3.736 → 1.3.738

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.
Files changed (69) hide show
  1. package/dist/commands/devPreflight.d.ts +21 -0
  2. package/dist/commands/devPreflight.d.ts.map +1 -1
  3. package/dist/commands/devPreflight.js +36 -2
  4. package/dist/commands/devPreflight.js.map +1 -1
  5. package/dist/commands/server.d.ts.map +1 -1
  6. package/dist/commands/server.js +30 -0
  7. package/dist/commands/server.js.map +1 -1
  8. package/dist/config/ConfigDefaults.d.ts.map +1 -1
  9. package/dist/config/ConfigDefaults.js +13 -0
  10. package/dist/config/ConfigDefaults.js.map +1 -1
  11. package/dist/core/PostUpdateMigrator.d.ts.map +1 -1
  12. package/dist/core/PostUpdateMigrator.js +12 -0
  13. package/dist/core/PostUpdateMigrator.js.map +1 -1
  14. package/dist/core/SessionManager.d.ts.map +1 -1
  15. package/dist/core/SessionManager.js +12 -0
  16. package/dist/core/SessionManager.js.map +1 -1
  17. package/dist/core/WriteDomainRegistry.d.ts.map +1 -1
  18. package/dist/core/WriteDomainRegistry.js +22 -0
  19. package/dist/core/WriteDomainRegistry.js.map +1 -1
  20. package/dist/core/hostSemaphoreCore.d.ts +115 -0
  21. package/dist/core/hostSemaphoreCore.d.ts.map +1 -0
  22. package/dist/core/hostSemaphoreCore.js +215 -0
  23. package/dist/core/hostSemaphoreCore.js.map +1 -0
  24. package/dist/core/hostSpawnSemaphore.d.ts +8 -9
  25. package/dist/core/hostSpawnSemaphore.d.ts.map +1 -1
  26. package/dist/core/hostSpawnSemaphore.js +48 -126
  27. package/dist/core/hostSpawnSemaphore.js.map +1 -1
  28. package/dist/core/hostTestRunnerSemaphore.d.ts +492 -0
  29. package/dist/core/hostTestRunnerSemaphore.d.ts.map +1 -0
  30. package/dist/core/hostTestRunnerSemaphore.js +1648 -0
  31. package/dist/core/hostTestRunnerSemaphore.js.map +1 -0
  32. package/dist/core/promptClauses.d.ts +110 -0
  33. package/dist/core/promptClauses.d.ts.map +1 -0
  34. package/dist/core/promptClauses.js +125 -0
  35. package/dist/core/promptClauses.js.map +1 -0
  36. package/dist/core/testRunnerRunClassifier.d.ts +128 -0
  37. package/dist/core/testRunnerRunClassifier.d.ts.map +1 -0
  38. package/dist/core/testRunnerRunClassifier.js +382 -0
  39. package/dist/core/testRunnerRunClassifier.js.map +1 -0
  40. package/dist/core/types.d.ts +21 -0
  41. package/dist/core/types.d.ts.map +1 -1
  42. package/dist/core/types.js.map +1 -1
  43. package/dist/data/llmBenchCoverage.d.ts +4 -0
  44. package/dist/data/llmBenchCoverage.d.ts.map +1 -1
  45. package/dist/data/llmBenchCoverage.js +72 -0
  46. package/dist/data/llmBenchCoverage.js.map +1 -1
  47. package/dist/monitoring/guardManifest.d.ts.map +1 -1
  48. package/dist/monitoring/guardManifest.js +26 -0
  49. package/dist/monitoring/guardManifest.js.map +1 -1
  50. package/dist/scaffold/templates.d.ts.map +1 -1
  51. package/dist/scaffold/templates.js +7 -0
  52. package/dist/scaffold/templates.js.map +1 -1
  53. package/dist/server/CapabilityIndex.d.ts.map +1 -1
  54. package/dist/server/CapabilityIndex.js +9 -0
  55. package/dist/server/CapabilityIndex.js.map +1 -1
  56. package/dist/server/routes.d.ts.map +1 -1
  57. package/dist/server/routes.js +150 -0
  58. package/dist/server/routes.js.map +1 -1
  59. package/package.json +1 -1
  60. package/scripts/lib/test-runner-selfdisable-patterns.mjs +326 -0
  61. package/scripts/pre-push-test-runner-selfdisable.mjs +81 -0
  62. package/src/data/builtin-manifest.json +64 -64
  63. package/src/data/llmBenchCoverage.ts +111 -0
  64. package/src/scaffold/templates.ts +7 -0
  65. package/upgrades/1.3.737.md +140 -0
  66. package/upgrades/1.3.738.md +73 -0
  67. package/upgrades/side-effects/authority-clause-standard.md +67 -0
  68. package/upgrades/side-effects/test-runner-concurrency-bound.md +173 -0
  69. package/upgrades/1.3.736.md +0 -57
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "instar",
3
- "version": "1.3.736",
3
+ "version": "1.3.738",
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",
@@ -0,0 +1,326 @@
1
+ /**
2
+ * test-runner-selfdisable-patterns.mjs — the SHARED serverless-host
3
+ * self-disable pattern detector for the test-runner concurrency bound
4
+ * (docs/specs/test-runner-concurrency-bound.md §2.6(b), §2.9, §5 last bullet).
5
+ *
6
+ * WHY THIS LIVES IN scripts/lib AS PLAIN ESM (one implementation, two
7
+ * consumers): the pre-push hook chain (scripts/pre-push-*.mjs) deliberately
8
+ * imports nothing from src/ or dist/ — a stale or missing dist must never
9
+ * break (let alone block) a push — while `instar dev:preflight`
10
+ * (src/commands/devPreflight.ts) runs its checks by SPAWNING commands.
11
+ * Implementing the detector once here and having dev:preflight spawn the same
12
+ * consumer script (scripts/pre-push-test-runner-selfdisable.mjs --preflight)
13
+ * keeps a single implementation reachable from both module systems.
14
+ *
15
+ * The paths + event shapes read here are the FROZEN rendezvous contract of
16
+ * spec §4 (canonical TS implementation: src/core/hostTestRunnerSemaphore.ts —
17
+ * resolveTestRunnerPaths / readLedgerTail / listLedgerSegments). This module
18
+ * re-reads the same frozen paths directly instead of importing the compiled
19
+ * module, per the dist-independence requirement above; the unit suite
20
+ * (tests/unit/test-runner-selfdisable-patterns.test.ts) pins the shared
21
+ * contract.
22
+ *
23
+ * ADVISORY-ONLY BY DESIGN: every read is best-effort and torn/malformed-line
24
+ * TOLERANT — a missing, unreadable, or garbage ledger yields ZERO findings
25
+ * and never throws out of the public entry points. Spec §2.6: "ledger content
26
+ * may only ever ADD a warning, never block a push."
27
+ *
28
+ * "SUSTAINED" (the spec leaves the threshold to the builder — documented
29
+ * choice): a pattern is flagged when >= DEFAULT_THRESHOLD (3) matching
30
+ * events land within DEFAULT_WINDOW_MS (48h) among the newest
31
+ * DEFAULT_MAX_EVENTS (400) ledger events (live file + newest 2 rotated
32
+ * segments). Rationale: one skip is a deliberate one-off, two may be a
33
+ * retry; three within two days is a pattern worth a WARN — and the surface
34
+ * is advisory-only, so a false positive costs one stderr line, never a
35
+ * blocked push.
36
+ */
37
+
38
+ import fs from 'node:fs';
39
+ import os from 'node:os';
40
+ import path from 'node:path';
41
+
42
+ export const DEFAULT_THRESHOLD = 3;
43
+ export const DEFAULT_WINDOW_MS = 48 * 60 * 60 * 1000; // 48h
44
+ export const DEFAULT_MAX_EVENTS = 400;
45
+ /** Newest rotated segments consulted in addition to the live ledger file. */
46
+ export const MAX_SEGMENTS_READ = 2;
47
+
48
+ // Code-default caps + pinned sanity ceilings (spec §2.9 — mirrors
49
+ // HOST_TEST_SUITE_CAP_DEFAULT/…CEILING in src/core/hostTestRunnerSemaphore.ts;
50
+ // pinned by the shared-contract unit test).
51
+ export const SUITE_CAP_DEFAULT = 1;
52
+ export const TARGETED_CAP_DEFAULT = 6;
53
+ export const SUITE_CAP_CEILING = 4;
54
+ export const TARGETED_CAP_CEILING = 24;
55
+ /** Cap stamps beyond this multiple of the host-uniform authority flag (§2.9). */
56
+ export const CAP_DIVERGENCE_FACTOR = 4;
57
+
58
+ /**
59
+ * The two patterns unambiguous enough for `dev:preflight` (which MAY fail per
60
+ * §2.6) to exit non-zero on: a sustained `off` kill-switch and sustained
61
+ * CI-reason skips on a non-CI host are both graded "like `off`" by the spec —
62
+ * deliberate self-disable signatures. The divergence/watch patterns keep
63
+ * plausible legitimate explanations (emergency env scoping, a deliberate
64
+ * interactive watch) and stay WARN-without-fail even in preflight. The
65
+ * pre-push surface NEVER fails on any of them (structural, §5 last bullet).
66
+ */
67
+ export const PREFLIGHT_FAIL_PATTERNS = ['sustained-off', 'ci-skip-non-ci-host'];
68
+
69
+ /** Frozen §4 rendezvous paths (mirror of resolveTestRunnerPaths — including
70
+ * the internal INSTAR_HOST_TEST_BASE_DIR test seam). */
71
+ export function resolveLedgerPaths(env = process.env) {
72
+ const override = env['INSTAR_HOST_TEST_BASE_DIR'];
73
+ const baseDir =
74
+ override && override.trim() ? override.trim() : path.join(os.homedir(), '.instar');
75
+ return {
76
+ baseDir,
77
+ ledger: path.join(baseDir, 'host-test-runner-events.jsonl'),
78
+ tuning: path.join(baseDir, 'host-test-runner-tuning.json'),
79
+ };
80
+ }
81
+
82
+ /** Hardened CI predicate (spec §2.6 — mirrors isCiEnvironment in
83
+ * src/core/testRunnerRunClassifier.ts): CI must be 'true'/'1' AND a positive
84
+ * CI signal must be present. */
85
+ export function isCiHost(env = process.env) {
86
+ const ci = env['CI'];
87
+ if (ci !== 'true' && ci !== '1') return false;
88
+ return env['GITHUB_ACTIONS'] !== undefined || env['RUNNER_OS'] !== undefined;
89
+ }
90
+
91
+ /** Rotated ledger segments, newest first (mirror of listLedgerSegments). */
92
+ export function listLedgerSegmentsNewestFirst(paths) {
93
+ try {
94
+ const base = path.basename(paths.ledger).replace(/\.jsonl$/, '');
95
+ return fs
96
+ .readdirSync(paths.baseDir)
97
+ .filter(
98
+ (f) =>
99
+ f.startsWith(`${base}.`) && f.endsWith('.jsonl') && f !== path.basename(paths.ledger),
100
+ )
101
+ .map((f) => path.join(paths.baseDir, f))
102
+ .sort()
103
+ .reverse();
104
+ } catch {
105
+ return []; // no base dir → no segments (nothing to warn about)
106
+ }
107
+ }
108
+
109
+ /**
110
+ * Bounded, torn/malformed-line-TOLERANT read of the newest ledger events
111
+ * across the live file + the newest MAX_SEGMENTS_READ rotated segments.
112
+ * Returns `{ events, ledgerPresent }` with events oldest→newest. Never
113
+ * throws; a missing/unreadable file is an empty contribution.
114
+ */
115
+ export function readLedgerEvents(paths, maxEvents = DEFAULT_MAX_EVENTS) {
116
+ const files = [
117
+ ...listLedgerSegmentsNewestFirst(paths).slice(0, MAX_SEGMENTS_READ).reverse(),
118
+ paths.ledger,
119
+ ];
120
+ const events = [];
121
+ let ledgerPresent = false;
122
+ for (const file of files) {
123
+ let raw;
124
+ try {
125
+ raw = fs.readFileSync(file, 'utf-8');
126
+ ledgerPresent = true;
127
+ } catch {
128
+ continue; // missing live file / segment — empty contribution
129
+ }
130
+ for (const line of raw.split('\n')) {
131
+ if (!line.trim()) continue;
132
+ try {
133
+ const obj = JSON.parse(line);
134
+ if (obj && typeof obj === 'object' && typeof obj.kind === 'string') {
135
+ events.push(obj);
136
+ }
137
+ } catch {
138
+ // torn/malformed ledger line — tolerated by contract (§2.6)
139
+ }
140
+ }
141
+ }
142
+ return { events: events.slice(-maxEvents), ledgerPresent };
143
+ }
144
+
145
+ function sanitizeCap(raw, codeDefault, ceiling) {
146
+ const n = typeof raw === 'number' ? raw : Number(raw);
147
+ if (!Number.isInteger(n) || n < 1 || n > ceiling) return codeDefault;
148
+ return n;
149
+ }
150
+
151
+ /**
152
+ * Tolerant read of the host-uniform tuning authority (spec §2.9). A missing /
153
+ * corrupt file or out-of-range values resolve to code-defaults — this
154
+ * detector must never throw on the same-user-writable file it reads.
155
+ */
156
+ export function readTuningAuthority(paths) {
157
+ let file = null;
158
+ try {
159
+ const obj = JSON.parse(fs.readFileSync(paths.tuning, 'utf-8'));
160
+ if (obj && typeof obj === 'object') file = obj;
161
+ } catch {
162
+ // absent/corrupt tuning file → code-default authority
163
+ }
164
+ return {
165
+ posture: file?.enforcing === true ? 'enforcing' : 'dry-run',
166
+ suiteCap: sanitizeCap(file?.maxConcurrent, SUITE_CAP_DEFAULT, SUITE_CAP_CEILING),
167
+ targetedCap: sanitizeCap(file?.targetedMax, TARGETED_CAP_DEFAULT, TARGETED_CAP_CEILING),
168
+ ttlSignal: file?.ttlSignal === true,
169
+ };
170
+ }
171
+
172
+ export function defaultAuthority() {
173
+ return {
174
+ posture: 'dry-run',
175
+ suiteCap: SUITE_CAP_DEFAULT,
176
+ targetedCap: TARGETED_CAP_DEFAULT,
177
+ ttlSignal: false,
178
+ };
179
+ }
180
+
181
+ /**
182
+ * The §2.6/§2.9 pattern list. Event shapes are the chokepoint's own ledger
183
+ * emissions (tests/setup/test-runner-semaphore.globalSetup.ts):
184
+ * skip → { kind:'skip', reason, loud }
185
+ * warn → { kind:'warn', warnType: 'posture-divergence'|'cap-divergence'|
186
+ * 'env-arm-ignored'|…, … }
187
+ * and every event carries resolved-state stamps (posture, suiteCap,
188
+ * targetedCap, ttlSignalArmed).
189
+ */
190
+ export const PATTERNS = [
191
+ {
192
+ id: 'sustained-off',
193
+ label: 'sustained INSTAR_HOST_TEST_SEMAPHORE=off skips',
194
+ match: (e) => e.kind === 'skip' && e.reason === 'off',
195
+ },
196
+ {
197
+ id: 'ci-skip-non-ci-host',
198
+ label: 'CI-reason skips ledgered on a NON-CI host (spoofed-CI signature; graded like `off`)',
199
+ // Only meaningful when the DETECTING host is not itself a CI runner —
200
+ // gated in detectSelfDisablePatterns().
201
+ requiresNonCiHost: true,
202
+ match: (e) => e.kind === 'skip' && e.reason === 'CI',
203
+ },
204
+ {
205
+ id: 'sustained-watch-skip',
206
+ label:
207
+ 'sustained defaulted/agent-context watch-mode skips (the labeled-innocent full-suite path)',
208
+ // `loud: true` is stamped exactly for defaulted-into-watch or
209
+ // agent-context watch skips (§2.6); a deliberate human interactive
210
+ // `--watch` is stamped loud:false and never flags.
211
+ match: (e) => e.kind === 'skip' && e.reason === 'watch' && e.loud === true,
212
+ },
213
+ {
214
+ id: 'resolved-cap-divergence',
215
+ label: 'resolved cap stamps exceed the host-uniform authority by >4× (env cap-inflation)',
216
+ // Explicit chokepoint warns OR stamp-derived (§2.9: "from the ledger's
217
+ // per-run cap stamps"). The 4× margin makes legitimate authority
218
+ // transitions (caps live in [1, ceiling]) unable to false-positive.
219
+ match: (e, ctx) =>
220
+ (e.kind === 'warn' && e.warnType === 'cap-divergence') ||
221
+ (Number.isFinite(e.suiteCap) &&
222
+ e.suiteCap > ctx.authority.suiteCap * CAP_DIVERGENCE_FACTOR) ||
223
+ (Number.isFinite(e.targetedCap) &&
224
+ e.targetedCap > ctx.authority.targetedCap * CAP_DIVERGENCE_FACTOR),
225
+ },
226
+ {
227
+ id: 'posture-divergence',
228
+ label: 'resolved posture diverged from the host-uniform authority (either direction)',
229
+ // Explicit chokepoint-emitted divergence WARNs only (both directions).
230
+ // Deliberately NOT stamp-inferred: comparing historical posture stamps
231
+ // against the CURRENT authority would false-positive across every
232
+ // legitimate posture flip, and the chokepoint already ledgers one
233
+ // explicit warn per divergent run — so a sustained divergence is
234
+ // >= threshold explicit warns by construction.
235
+ match: (e) => e.kind === 'warn' && e.warnType === 'posture-divergence',
236
+ },
237
+ {
238
+ id: 'arm-divergence',
239
+ label:
240
+ 'env TTL_SIGNAL=1 ignored against an unarmed authority (arming is tuning-file-only)',
241
+ match: (e) => e.kind === 'warn' && e.warnType === 'env-arm-ignored',
242
+ },
243
+ ];
244
+
245
+ /**
246
+ * Pure pattern detection over parsed ledger events. Returns
247
+ * `{ findings, eventsScanned }`; a finding is
248
+ * `{ pattern, label, count, threshold, windowMs, lastTs }`.
249
+ * Events with an unparseable `ts` are excluded (never flag on garbage).
250
+ */
251
+ export function detectSelfDisablePatterns(events, opts = {}) {
252
+ const threshold = opts.threshold ?? DEFAULT_THRESHOLD;
253
+ const windowMs = opts.windowMs ?? DEFAULT_WINDOW_MS;
254
+ const nowMs = opts.nowMs ?? Date.now();
255
+ const ciHost = opts.isCiHost ?? isCiHost(opts.env ?? process.env);
256
+ const authority = opts.authority ?? defaultAuthority();
257
+ const ctx = { authority };
258
+
259
+ const recent = (Array.isArray(events) ? events : []).filter((e) => {
260
+ const t = Date.parse(e?.ts);
261
+ return Number.isFinite(t) && nowMs - t <= windowMs;
262
+ });
263
+
264
+ const findings = [];
265
+ for (const p of PATTERNS) {
266
+ if (p.requiresNonCiHost && ciHost) continue;
267
+ const matched = recent.filter((e) => {
268
+ try {
269
+ return p.match(e, ctx) === true;
270
+ } catch {
271
+ return false; // a hostile/degenerate event never breaks detection
272
+ }
273
+ });
274
+ if (matched.length >= threshold) {
275
+ findings.push({
276
+ pattern: p.id,
277
+ label: p.label,
278
+ count: matched.length,
279
+ threshold,
280
+ windowMs,
281
+ lastTs: typeof matched[matched.length - 1]?.ts === 'string'
282
+ ? matched[matched.length - 1].ts
283
+ : null,
284
+ });
285
+ }
286
+ }
287
+ return { findings, eventsScanned: recent.length };
288
+ }
289
+
290
+ /**
291
+ * Full composition: resolve paths → read ledger (live + newest segments) →
292
+ * read tuning authority → detect. A missing/unreadable ledger returns
293
+ * `{ ledgerPresent: false, findings: [] }` — no ledger, nothing to warn.
294
+ * Never throws.
295
+ */
296
+ export function runSelfDisableCheck(opts = {}) {
297
+ try {
298
+ const env = opts.env ?? process.env;
299
+ const paths = opts.paths ?? resolveLedgerPaths(env);
300
+ const { events, ledgerPresent } = readLedgerEvents(paths, opts.maxEvents ?? DEFAULT_MAX_EVENTS);
301
+ if (!ledgerPresent) {
302
+ return { ledgerPresent: false, findings: [], eventsScanned: 0, authority: null };
303
+ }
304
+ const authority = opts.authority ?? readTuningAuthority(paths);
305
+ const { findings, eventsScanned } = detectSelfDisablePatterns(events, {
306
+ ...opts,
307
+ env,
308
+ authority,
309
+ });
310
+ return { ledgerPresent: true, findings, eventsScanned, authority };
311
+ } catch {
312
+ // Advisory surface: any unexpected internal failure is "nothing to warn".
313
+ return { ledgerPresent: false, findings: [], eventsScanned: 0, authority: null };
314
+ }
315
+ }
316
+
317
+ /** Human-readable WARN lines (one per finding) for stderr surfaces. */
318
+ export function formatWarnLines(findings) {
319
+ return (findings ?? []).map(
320
+ (f) =>
321
+ `WARN [test-runner-bound self-disable] ${f.label ?? f.pattern}: ` +
322
+ `${f.count} event(s) within ${Math.round(f.windowMs / 3_600_000)}h ` +
323
+ `(threshold ${f.threshold}${f.lastTs ? `, latest ${f.lastTs}` : ''}) — ` +
324
+ `advisory only; a silently-disabled guard explains more incidents than a broken one (spec §2.6).`,
325
+ );
326
+ }
@@ -0,0 +1,81 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * pre-push-test-runner-selfdisable.mjs — the serverless-host self-disable
4
+ * advisory for the test-runner concurrency bound
5
+ * (docs/specs/test-runner-concurrency-bound.md §2.6(b), §2.9, §5 last bullet).
6
+ *
7
+ * Reads the LOCAL host-test-runner event ledger (live file + newest rotated
8
+ * segments) and WARNs on a sustained self-disable pattern: `off` skips,
9
+ * CI-reason skips on a non-CI host, defaulted/agent-context watch skips,
10
+ * resolved-cap divergence (>4× authority), posture divergence (either
11
+ * direction), and env-arm-ignored events. Detection logic lives ONCE in
12
+ * scripts/lib/test-runner-selfdisable-patterns.mjs (shared with
13
+ * `instar dev:preflight`, which spawns this script with --preflight).
14
+ *
15
+ * STRUCTURAL CONTRACT (spec §2.6 round-4 security + §5 last bullet): in
16
+ * pre-push mode this script exits 0 UNCONDITIONALLY — on findings, on a
17
+ * missing ledger, on a corrupt ledger, and on any internal error. The
18
+ * detection surface for a self-disable must not itself become a new way to
19
+ * wedge the dev loop (§1.1's false-BLOCK inversion): ledger content may only
20
+ * ever ADD a warning, never block a push. A missing/unreadable ledger is
21
+ * silent (no ledger = nothing to warn about).
22
+ * tests/unit/test-runner-selfdisable-patterns.test.ts pins this by spawning
23
+ * this real script against a pattern-heavy fixture ledger and asserting
24
+ * exit 0 with WARN on stderr.
25
+ *
26
+ * --preflight mode (spawned by `instar dev:preflight`, a deliberate human
27
+ * advisory tool that MAY fail per §2.6): exits 1 ONLY for the two unambiguous
28
+ * self-disable signatures (sustained `off`; spoofed CI on a non-CI host —
29
+ * both graded "like `off`" by the spec). The divergence/watch patterns WARN
30
+ * without failing even there (they keep plausible legitimate explanations —
31
+ * emergency env scoping, a deliberate interactive watch).
32
+ */
33
+
34
+ import {
35
+ PREFLIGHT_FAIL_PATTERNS,
36
+ formatWarnLines,
37
+ runSelfDisableCheck,
38
+ } from './lib/test-runner-selfdisable-patterns.mjs';
39
+
40
+ const preflightMode = process.argv.includes('--preflight');
41
+ let preflightExit = 0;
42
+
43
+ try {
44
+ const result = runSelfDisableCheck();
45
+ if (result.ledgerPresent && result.findings.length > 0) {
46
+ for (const line of formatWarnLines(result.findings)) {
47
+ process.stderr.write(`⚠️ ${line}\n`);
48
+ }
49
+ if (
50
+ preflightMode &&
51
+ result.findings.some((f) => PREFLIGHT_FAIL_PATTERNS.includes(f.pattern))
52
+ ) {
53
+ preflightExit = 1;
54
+ process.stderr.write(
55
+ 'preflight: unambiguous self-disable pattern (sustained `off` / spoofed CI) — ' +
56
+ 'failing advisorily (§2.6: dev:preflight MAY fail; the pre-push surface never does).\n',
57
+ );
58
+ }
59
+ } else if (preflightMode) {
60
+ process.stdout.write(
61
+ 'test-runner bound self-disable ledger check: clean (no sustained pattern, or no local ledger yet).\n',
62
+ );
63
+ }
64
+ // Pre-push mode: a missing/unreadable ledger or zero findings is SILENT.
65
+ } catch (err) {
66
+ // Never let the detection surface wedge the dev loop (§1.1): any internal
67
+ // error is advisory noise at most, in BOTH modes.
68
+ try {
69
+ process.stderr.write(
70
+ `test-runner self-disable check errored (advisory, ignored): ${
71
+ err instanceof Error ? err.message : String(err)
72
+ }\n`,
73
+ );
74
+ } catch {
75
+ /* stderr unavailable — nothing left to do */
76
+ }
77
+ }
78
+
79
+ // WARN-only: pre-push mode exits 0 unconditionally; only --preflight may
80
+ // carry the advisory failure (and only for PREFLIGHT_FAIL_PATTERNS).
81
+ process.exit(preflightMode ? preflightExit : 0);