@bookedsolid/rea 0.27.0 → 0.28.1

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.
@@ -0,0 +1,386 @@
1
+ /**
2
+ * `rea verify-claim <claim-id>` — replay a recorded security-claim PoC
3
+ * battery against the currently-installed (or in-tree dogfood) rea CLI.
4
+ *
5
+ * The centerpiece of 0.28.0 (4th structural pivot — claims as
6
+ * machine-verifiable artifacts rather than prose-only release notes).
7
+ *
8
+ * Each claim lives at `data/claims/<id>.json` and lists 1..N PoCs.
9
+ * Every PoC has a `type` that names the executor:
10
+ *
11
+ * - `scan-bash` (primary): pipes `input` into
12
+ * `dist/cli/index.js hook scan-bash --mode <protected|blocked>` and
13
+ * compares the resulting verdict to `expected_verdict`.
14
+ * - `shellcheck` (helix-031 case): runs shellcheck on `target` and
15
+ * asserts the run is clean (no SC<code> warnings).
16
+ *
17
+ * Resolution order for the rea CLI under test:
18
+ *
19
+ * - `--installed` → resolves to `<cwd>/node_modules/@bookedsolid/rea/dist/cli/index.js`.
20
+ * This is the canonical "verify against MY pinned rea" mode for
21
+ * consumers — tells them whether the version they actually have
22
+ * installed still rejects the PoCs the claim targets.
23
+ * - default → uses the same `dist/cli/index.js` that ships with the
24
+ * CLI itself (i.e. the rea repo's own dogfood). Resolved relative
25
+ * to the running script.
26
+ *
27
+ * Exit codes:
28
+ *
29
+ * - 0 — every PoC matched the recorded `expected_verdict`.
30
+ * - 1 — at least one PoC mismatched (regression — investigate).
31
+ * - 2 — claim id is unknown / no JSON file at `data/claims/<id>.json`.
32
+ */
33
+ import fs from 'node:fs';
34
+ import path from 'node:path';
35
+ import { spawnSync } from 'node:child_process';
36
+ import { fileURLToPath } from 'node:url';
37
+ import { err } from './utils.js';
38
+ // ---------------------------------------------------------------------------
39
+ // Loader
40
+ // ---------------------------------------------------------------------------
41
+ /**
42
+ * Resolve the directory holding the bundled claim JSON files. Walks up
43
+ * from the running script (or from this file at dev time) looking for
44
+ * a `data/claims/` sibling. Returns null when the directory cannot be
45
+ * located — the caller falls back to whatever `claimsDir` override was
46
+ * passed.
47
+ */
48
+ export function resolveDefaultClaimsDir() {
49
+ // The compiled CLI runs from `dist/cli/index.js` — walk up to the
50
+ // package root, then look for `data/claims/`.
51
+ const here = path.dirname(fileURLToPath(import.meta.url));
52
+ let cur = here;
53
+ for (let i = 0; i < 8 && cur && cur !== path.dirname(cur); i += 1) {
54
+ const cand = path.join(cur, 'data', 'claims');
55
+ if (fs.existsSync(cand))
56
+ return cand;
57
+ cur = path.dirname(cur);
58
+ }
59
+ return null;
60
+ }
61
+ /**
62
+ * Load and validate a claim file. Throws on malformed JSON or shape
63
+ * mismatch — `runVerifyClaim` translates the throw into exit-code 2 +
64
+ * a stderr message.
65
+ */
66
+ export function loadClaim(claimsDir, claimId) {
67
+ // Defensive: claim ids are constrained to a kebab-case shape so a
68
+ // crafted argv can't escape the directory ('../../etc/passwd'). The
69
+ // CLI argument is also passed verbatim to fs.readFileSync, so a
70
+ // non-conforming id should hard-fail before any disk access.
71
+ if (!/^[a-z0-9][a-z0-9._-]*$/i.test(claimId)) {
72
+ throw new Error(`verify-claim: invalid claim id ${JSON.stringify(claimId)} ` +
73
+ `(allowed: kebab-case [a-z0-9][a-z0-9._-]*)`);
74
+ }
75
+ const file = path.join(claimsDir, `${claimId}.json`);
76
+ if (!fs.existsSync(file)) {
77
+ throw new Error(`verify-claim: unknown claim id ${JSON.stringify(claimId)} (expected ${file})`);
78
+ }
79
+ let raw;
80
+ try {
81
+ raw = fs.readFileSync(file, 'utf8');
82
+ }
83
+ catch (e) {
84
+ throw new Error(`verify-claim: could not read ${file}: ${e instanceof Error ? e.message : String(e)}`);
85
+ }
86
+ let parsed;
87
+ try {
88
+ parsed = JSON.parse(raw);
89
+ }
90
+ catch (e) {
91
+ throw new Error(`verify-claim: ${file} is not valid JSON: ${e instanceof Error ? e.message : String(e)}`);
92
+ }
93
+ return validateClaim(parsed, file);
94
+ }
95
+ function validateClaim(parsed, source) {
96
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
97
+ throw new Error(`verify-claim: ${source} top-level must be an object`);
98
+ }
99
+ const obj = parsed;
100
+ const id = obj.id;
101
+ const title = obj.title;
102
+ const introducedIn = obj.introduced_in;
103
+ const closedIn = obj.closed_in;
104
+ const summary = obj.summary;
105
+ const pocs = obj.pocs;
106
+ if (typeof id !== 'string' || id.length === 0) {
107
+ throw new Error(`verify-claim: ${source} requires a non-empty string \`id\``);
108
+ }
109
+ if (typeof title !== 'string' || title.length === 0) {
110
+ throw new Error(`verify-claim: ${source} requires a non-empty string \`title\``);
111
+ }
112
+ if (typeof introducedIn !== 'string' || introducedIn.length === 0) {
113
+ throw new Error(`verify-claim: ${source} requires a non-empty string \`introduced_in\``);
114
+ }
115
+ if (typeof closedIn !== 'string' || closedIn.length === 0) {
116
+ throw new Error(`verify-claim: ${source} requires a non-empty string \`closed_in\``);
117
+ }
118
+ if (!Array.isArray(pocs) || pocs.length === 0) {
119
+ throw new Error(`verify-claim: ${source} requires a non-empty \`pocs\` array`);
120
+ }
121
+ const validatedPocs = pocs.map((p, idx) => validatePoC(p, source, idx));
122
+ const claim = {
123
+ id,
124
+ title,
125
+ introduced_in: introducedIn,
126
+ closed_in: closedIn,
127
+ pocs: validatedPocs,
128
+ };
129
+ if (typeof summary === 'string' && summary.length > 0) {
130
+ claim.summary = summary;
131
+ }
132
+ return claim;
133
+ }
134
+ function validatePoC(parsed, source, index) {
135
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
136
+ throw new Error(`verify-claim: ${source} pocs[${index}] must be an object`);
137
+ }
138
+ const obj = parsed;
139
+ const id = obj.id;
140
+ const type = obj.type;
141
+ if (typeof id !== 'string' || id.length === 0) {
142
+ throw new Error(`verify-claim: ${source} pocs[${index}].id must be a non-empty string`);
143
+ }
144
+ if (type === 'scan-bash') {
145
+ const input = obj.input;
146
+ const mode = obj.mode;
147
+ const expected = obj.expected_verdict;
148
+ if (typeof input !== 'string') {
149
+ throw new Error(`verify-claim: ${source} pocs[${index}].input must be a string`);
150
+ }
151
+ if (mode !== 'protected' && mode !== 'blocked') {
152
+ throw new Error(`verify-claim: ${source} pocs[${index}].mode must be 'protected' | 'blocked'`);
153
+ }
154
+ if (expected !== 'allow' && expected !== 'block') {
155
+ throw new Error(`verify-claim: ${source} pocs[${index}].expected_verdict must be 'allow' | 'block'`);
156
+ }
157
+ return { id, type: 'scan-bash', input, mode, expected_verdict: expected };
158
+ }
159
+ if (type === 'shellcheck') {
160
+ const target = obj.target;
161
+ const expected = obj.expected_verdict;
162
+ if (typeof target !== 'string' || target.length === 0) {
163
+ throw new Error(`verify-claim: ${source} pocs[${index}].target must be a non-empty string`);
164
+ }
165
+ if (expected !== 'clean') {
166
+ throw new Error(`verify-claim: ${source} pocs[${index}].expected_verdict must be 'clean'`);
167
+ }
168
+ return { id, type: 'shellcheck', target, expected_verdict: expected };
169
+ }
170
+ throw new Error(`verify-claim: ${source} pocs[${index}].type must be 'scan-bash' | 'shellcheck' (got ${JSON.stringify(type)})`);
171
+ }
172
+ // ---------------------------------------------------------------------------
173
+ // Executors
174
+ // ---------------------------------------------------------------------------
175
+ /**
176
+ * Resolve the rea CLI to invoke for `scan-bash` PoCs.
177
+ *
178
+ * Precedence: cliOverride > --installed > sibling dogfood dist/cli/index.js.
179
+ *
180
+ * Returns a pair `[command, args]` so the caller can do
181
+ * `spawnSync(cmd, [...args, 'hook', 'scan-bash', ...])`. The shape
182
+ * keeps node-vs-direct-binary differences localized to this resolver.
183
+ */
184
+ export function resolveCli(opts) {
185
+ if (opts.cliOverride !== undefined && opts.cliOverride.length > 0) {
186
+ const abs = path.resolve(opts.cliOverride);
187
+ return { cmd: process.execPath, args: [abs], path: abs };
188
+ }
189
+ if (opts.installed === true) {
190
+ const cwd = opts.cwd ?? process.cwd();
191
+ const installed = path.join(cwd, 'node_modules', '@bookedsolid', 'rea', 'dist', 'cli', 'index.js');
192
+ if (!fs.existsSync(installed)) {
193
+ throw new Error(`verify-claim --installed: not found at ${installed}. ` +
194
+ `Install @bookedsolid/rea in the current project.`);
195
+ }
196
+ return { cmd: process.execPath, args: [installed], path: installed };
197
+ }
198
+ // Default: walk up from this file to find the dogfood dist/cli/index.js.
199
+ const here = path.dirname(fileURLToPath(import.meta.url));
200
+ let cur = here;
201
+ for (let i = 0; i < 8 && cur && cur !== path.dirname(cur); i += 1) {
202
+ const cand = path.join(cur, 'dist', 'cli', 'index.js');
203
+ if (fs.existsSync(cand)) {
204
+ return { cmd: process.execPath, args: [cand], path: cand };
205
+ }
206
+ cur = path.dirname(cur);
207
+ }
208
+ throw new Error('verify-claim: could not locate dist/cli/index.js. Run `pnpm build` or pass --installed.');
209
+ }
210
+ /**
211
+ * Run a single PoC against the resolved CLI. Pure function — no global
212
+ * state, all dependencies threaded through `cliCmd` / `cliArgs` / `spawn`.
213
+ * Tests substitute `spawn` with a fake.
214
+ */
215
+ export function runPoC(poc, cliCmd, cliArgs, spawn = spawnSync, cwd = process.cwd()) {
216
+ if (poc.type === 'scan-bash') {
217
+ const args = [...cliArgs, 'hook', 'scan-bash', '--mode', poc.mode];
218
+ const result = spawn(cliCmd, args, { input: poc.input, encoding: 'utf8', timeout: 30_000 });
219
+ let actual = 'error';
220
+ let detail = '';
221
+ if (result.error) {
222
+ detail = `spawn error: ${result.error.message}`;
223
+ }
224
+ else {
225
+ // CLI contract: exit 0 = allow, 2 = block, 1 = error. Stdout
226
+ // carries the verdict JSON. Prefer the JSON shape (richer, but
227
+ // exit code is the floor).
228
+ const stdout = result.stdout ?? '';
229
+ try {
230
+ const parsed = JSON.parse(stdout.trim());
231
+ if (parsed.verdict === 'allow' || parsed.verdict === 'block') {
232
+ actual = parsed.verdict;
233
+ }
234
+ else {
235
+ detail = `verdict JSON missing valid \`verdict\` field; stdout=${stdout.slice(0, 200)}`;
236
+ }
237
+ }
238
+ catch {
239
+ if (result.status === 0) {
240
+ actual = 'allow';
241
+ }
242
+ else if (result.status === 2) {
243
+ actual = 'block';
244
+ }
245
+ else {
246
+ detail = `unparseable stdout; exit=${result.status} stdout=${stdout.slice(0, 200)} stderr=${(result.stderr ?? '').slice(0, 200)}`;
247
+ }
248
+ }
249
+ }
250
+ const match = actual === poc.expected_verdict;
251
+ return {
252
+ poc_id: poc.id,
253
+ type: 'scan-bash',
254
+ expected: poc.expected_verdict,
255
+ actual,
256
+ match,
257
+ detail: match ? '' : detail.length > 0 ? detail : `expected ${poc.expected_verdict}, got ${actual}`,
258
+ };
259
+ }
260
+ // shellcheck
261
+ const target = path.isAbsolute(poc.target) ? poc.target : path.join(cwd, poc.target);
262
+ // -S error excludes warnings/info; but the claim contract is "no SC<code>
263
+ // warnings" — keep severity at the default (warning) so SC1078 surfaces.
264
+ // We allow stderr to be non-empty (shellcheck prints debug noise on
265
+ // some versions) — only the exit code + stdout-line count matters.
266
+ const result = spawn('shellcheck', [target], { encoding: 'utf8', timeout: 30_000 });
267
+ if (result.error !== undefined) {
268
+ // shellcheck not installed or otherwise broken. Treat as
269
+ // "indeterminate" — we can't refute the claim without the tool, so
270
+ // the safer posture is to FAIL the verification so a missing
271
+ // shellcheck doesn't silently bless every claim.
272
+ return {
273
+ poc_id: poc.id,
274
+ type: 'shellcheck',
275
+ expected: 'clean',
276
+ actual: 'error',
277
+ match: false,
278
+ detail: `shellcheck unavailable: ${result.error.message}`,
279
+ };
280
+ }
281
+ const clean = result.status === 0 && (result.stdout ?? '').trim().length === 0;
282
+ return {
283
+ poc_id: poc.id,
284
+ type: 'shellcheck',
285
+ expected: 'clean',
286
+ actual: clean ? 'clean' : 'warnings',
287
+ match: clean,
288
+ detail: clean
289
+ ? ''
290
+ : `shellcheck exit=${result.status}; output=${(result.stdout ?? '').slice(0, 400)}`,
291
+ };
292
+ }
293
+ /**
294
+ * Run all PoCs in a claim. Pure — exposed so tests can drive without
295
+ * spawning processes if they substitute `spawn`.
296
+ */
297
+ export function runVerifyClaimSync(claim, cliCmd, cliArgs, cliPath, spawn = spawnSync, cwd = process.cwd()) {
298
+ const results = [];
299
+ let matched = 0;
300
+ let mismatched = 0;
301
+ for (const poc of claim.pocs) {
302
+ const r = runPoC(poc, cliCmd, cliArgs, spawn, cwd);
303
+ results.push(r);
304
+ if (r.match)
305
+ matched += 1;
306
+ else
307
+ mismatched += 1;
308
+ }
309
+ return {
310
+ claim_id: claim.id,
311
+ cli: cliPath,
312
+ total: claim.pocs.length,
313
+ matched,
314
+ mismatched,
315
+ results,
316
+ exit_code: mismatched > 0 ? 1 : 0,
317
+ };
318
+ }
319
+ // ---------------------------------------------------------------------------
320
+ // CLI entry
321
+ // ---------------------------------------------------------------------------
322
+ export async function runVerifyClaim(claimId, opts) {
323
+ const claimsDir = opts.claimsDir ?? resolveDefaultClaimsDir();
324
+ if (claimsDir === null) {
325
+ err('verify-claim: could not locate data/claims/ directory. ' +
326
+ 'This is a bug in the install or a stripped tarball.');
327
+ process.exit(2);
328
+ }
329
+ let claim;
330
+ try {
331
+ claim = loadClaim(claimsDir, claimId);
332
+ }
333
+ catch (e) {
334
+ err(e instanceof Error ? e.message : String(e));
335
+ process.exit(2);
336
+ }
337
+ let resolved;
338
+ try {
339
+ resolved = resolveCli(opts);
340
+ }
341
+ catch (e) {
342
+ err(e instanceof Error ? e.message : String(e));
343
+ process.exit(2);
344
+ }
345
+ const result = runVerifyClaimSync(claim, resolved.cmd, resolved.args, resolved.path, spawnSync, opts.cwd ?? process.cwd());
346
+ if (opts.json === true) {
347
+ process.stdout.write(JSON.stringify(result) + '\n');
348
+ }
349
+ else {
350
+ // Human-readable summary on stderr — keeps stdout clean for jq pipes
351
+ // (consistent with `rea status`, `rea hook codex-review`).
352
+ process.stderr.write(`[verify-claim] ${claim.id} — ${claim.title}\n`);
353
+ process.stderr.write(`[verify-claim] cli=${resolved.path}\n`);
354
+ process.stderr.write(`[verify-claim] introduced_in=${claim.introduced_in} closed_in=${claim.closed_in}\n`);
355
+ for (const r of result.results) {
356
+ const tag = r.match ? 'PASS' : 'FAIL';
357
+ process.stderr.write(`[verify-claim] ${tag} ${r.poc_id} expected=${r.expected} actual=${r.actual}` +
358
+ (r.detail.length > 0 ? ` (${r.detail})` : '') +
359
+ '\n');
360
+ }
361
+ process.stderr.write(`[verify-claim] ${result.matched}/${result.total} PoCs matched (mismatched=${result.mismatched})\n`);
362
+ }
363
+ process.exit(result.exit_code);
364
+ }
365
+ /**
366
+ * Attach `rea verify-claim <claim-id>` to the commander program.
367
+ */
368
+ export function registerVerifyClaimCommand(program) {
369
+ program
370
+ .command('verify-claim')
371
+ .description('Replay a recorded security-claim PoC battery against the rea CLI under test. ' +
372
+ 'Each claim at `data/claims/<id>.json` lists 1..N PoCs (scan-bash inputs or ' +
373
+ 'shellcheck targets) with expected verdicts. Exit 0 = all matched, 1 = mismatch, ' +
374
+ '2 = unknown claim id.')
375
+ .argument('<claim-id>', 'claim identifier (kebab-case; corresponds to data/claims/<id>.json)')
376
+ .option('--installed', 'verify against `node_modules/@bookedsolid/rea/dist/cli/index.js` ' +
377
+ 'in the current working directory (consumer-pinned version) ' +
378
+ 'instead of the dogfood build')
379
+ .option('--json', 'emit a single-line JSON result on stdout')
380
+ .action(async (claimId, opts) => {
381
+ await runVerifyClaim(claimId, {
382
+ ...(opts.installed === true ? { installed: true } : {}),
383
+ ...(opts.json === true ? { json: true } : {}),
384
+ });
385
+ });
386
+ }
@@ -29,6 +29,23 @@ export interface DownstreamHealth {
29
29
  healthy: boolean;
30
30
  /** Last error observed, or null if the connection is clean or never errored. */
31
31
  last_error: string | null;
32
+ /**
33
+ * 0.28.0 helix-025 F1 — explicit tri-state for the connection lifecycle.
34
+ *
35
+ * - `'never'` — the downstream has not yet been attempted (gateway
36
+ * boot before the first `connectAll` lands, or connection refused
37
+ * so early no error message reached the supervisor)
38
+ * - `'ok'` — most recent connect / call succeeded
39
+ * - `'errored'` — currently in an error state; `last_error` is set
40
+ *
41
+ * Pre-fix the helix consumer saw `connected: false, healthy: false,
42
+ * last_error: null` after a downstream's child failed to spawn — the
43
+ * agent had no way to tell whether the connection had been attempted
44
+ * at all. The tri-state distinguishes "never tried" from "tried and
45
+ * failed" even when the underlying error never produced a renderable
46
+ * string (e.g. ECONNREFUSED before the supervisor wired its hooks).
47
+ */
48
+ connection_state: 'never' | 'ok' | 'errored';
32
49
  /**
33
50
  * Number of tools advertised by the downstream on the most recent
34
51
  * successful `tools/list`, or null when never listed / listing failed.
@@ -120,6 +120,7 @@ export class DownstreamPool {
120
120
  connected,
121
121
  healthy,
122
122
  last_error: conn.lastError,
123
+ connection_state: conn.connectionState,
123
124
  tools_count,
124
125
  });
125
126
  }
@@ -188,6 +188,15 @@ export declare class DownstreamConnection {
188
188
  * instead of watching the child die again.
189
189
  */
190
190
  private unexpectedDeathAt;
191
+ /**
192
+ * 0.28.0 helix-025 F1 — flips to true the first time `connect()` is
193
+ * invoked (regardless of outcome). Drives the `'never'` arm of the
194
+ * tri-state surfaced via `connectionState`. Any path that touches
195
+ * `this.client`, `this.#lastErrorMessage`, or `this.health` runs
196
+ * AFTER `connect()` has set this — so a single boolean is sufficient
197
+ * to tell "supervisor has tried at least once" from "never attempted".
198
+ */
199
+ private everAttemptedConnect;
191
200
  private health;
192
201
  /**
193
202
  * Optional supervisor-event listener. Set via
@@ -278,6 +287,22 @@ export declare class DownstreamConnection {
278
287
  * backing field instead of going through the setter).
279
288
  */
280
289
  get lastError(): string | null;
290
+ /**
291
+ * 0.28.0 helix-025 F1 — explicit tri-state for the lifecycle:
292
+ *
293
+ * `'never'` — connect() has not yet been called (the connection
294
+ * was constructed but the gateway hasn't gotten to
295
+ * the connectAll loop, or the entire pool hasn't
296
+ * booted)
297
+ * `'ok'` — the most recent connect/call cleared lastError;
298
+ * the supervisor considers the link live
299
+ * `'errored'` — there is a current error or the connection is
300
+ * unhealthy after at least one attempt
301
+ *
302
+ * The tri-state is derived — no separate state machine — so it
303
+ * cannot drift from the underlying connect/error flow.
304
+ */
305
+ get connectionState(): 'never' | 'ok' | 'errored';
281
306
  connect(): Promise<void>;
282
307
  listTools(): Promise<DownstreamToolInfo[]>;
283
308
  /**
@@ -174,6 +174,15 @@ export class DownstreamConnection {
174
174
  * instead of watching the child die again.
175
175
  */
176
176
  unexpectedDeathAt = 0;
177
+ /**
178
+ * 0.28.0 helix-025 F1 — flips to true the first time `connect()` is
179
+ * invoked (regardless of outcome). Drives the `'never'` arm of the
180
+ * tri-state surfaced via `connectionState`. Any path that touches
181
+ * `this.client`, `this.#lastErrorMessage`, or `this.health` runs
182
+ * AFTER `connect()` has set this — so a single boolean is sufficient
183
+ * to tell "supervisor has tried at least once" from "never attempted".
184
+ */
185
+ everAttemptedConnect = false;
177
186
  health = 'healthy';
178
187
  /**
179
188
  * Optional supervisor-event listener. Set via
@@ -378,9 +387,40 @@ export class DownstreamConnection {
378
387
  return null;
379
388
  return boundedDiagnosticString(raw);
380
389
  }
390
+ /**
391
+ * 0.28.0 helix-025 F1 — explicit tri-state for the lifecycle:
392
+ *
393
+ * `'never'` — connect() has not yet been called (the connection
394
+ * was constructed but the gateway hasn't gotten to
395
+ * the connectAll loop, or the entire pool hasn't
396
+ * booted)
397
+ * `'ok'` — the most recent connect/call cleared lastError;
398
+ * the supervisor considers the link live
399
+ * `'errored'` — there is a current error or the connection is
400
+ * unhealthy after at least one attempt
401
+ *
402
+ * The tri-state is derived — no separate state machine — so it
403
+ * cannot drift from the underlying connect/error flow.
404
+ */
405
+ get connectionState() {
406
+ if (!this.everAttemptedConnect)
407
+ return 'never';
408
+ if (this.health === 'unhealthy')
409
+ return 'errored';
410
+ if (this.#lastErrorMessage !== null)
411
+ return 'errored';
412
+ return 'ok';
413
+ }
381
414
  async connect() {
382
415
  if (this.client !== null)
383
416
  return;
417
+ // 0.28.0 helix-025 F1: stamp the "ever-attempted" flag BEFORE any
418
+ // failure paths fire — a connect() that throws on env-resolution
419
+ // still counts as "we tried", so the tri-state moves out of
420
+ // `'never'` even when no error string is renderable. The flag
421
+ // never resets; once attempted, the connection is in 'ok' or
422
+ // 'errored' for the rest of its life.
423
+ this.everAttemptedConnect = true;
384
424
  // Resolve env BEFORE spawning. If any `${VAR}` reference in the registry's
385
425
  // explicit env: map is unset at startup, refuse to spawn this server:
386
426
  // - log a clear, secret-safe error (only the var name appears; the
@@ -85,6 +85,18 @@ export interface LiveDownstreamState {
85
85
  /** ISO timestamp when the circuit is expected to move to half-open. Only present when `open`. */
86
86
  retry_at: string | null;
87
87
  last_error: string | null;
88
+ /**
89
+ * 0.28.0 helix-025 F1 — explicit tri-state for the connection
90
+ * lifecycle. `'never'` means the supervisor has not attempted to
91
+ * connect yet; `'ok'` means the most recent attempt cleared
92
+ * lastError; `'errored'` means a connect or call failed and the
93
+ * downstream is currently considered unhealthy. Mirrors
94
+ * `DownstreamHealth.connection_state` in `downstream-pool.ts`.
95
+ * Optional in the type to keep older state-file readers
96
+ * compatible — pre-0.28.0 snapshots that lack the field surface
97
+ * as `null` in `rea status` rather than crashing the parse.
98
+ */
99
+ connection_state?: 'never' | 'ok' | 'errored';
88
100
  tools_count: number | null;
89
101
  /** Cumulative circuit-open transitions counted toward SESSION_BLOCKER. */
90
102
  open_transitions: number;
@@ -496,6 +496,7 @@ export class LiveStatePublisher {
496
496
  circuit_state: circuitState,
497
497
  retry_at: retryAt,
498
498
  last_error: lastError,
499
+ connection_state: h.connection_state,
499
500
  tools_count: h.tools_count,
500
501
  open_transitions: blocker?.open_transitions ?? 0,
501
502
  session_blocker_emitted: blocker?.emitted ?? false,