@brunobracaioli/aibrain 0.3.0-beta.7 → 0.4.0-beta.2

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,194 @@
1
+ // @ts-check
2
+
3
+ /**
4
+ * AISAC Engineering license check (ADR-0047 wave E3).
5
+ *
6
+ * The server is the only authority. This answer is shown to the user and
7
+ * never unlocks anything locally: every failure is reported as
8
+ * `unavailable`, never as licensed. The account token goes only to the
9
+ * configured AI Brain origin, redirects are refused, and the response is
10
+ * size-bounded and validated before any field is displayed.
11
+ */
12
+
13
+ const LICENSE_PATH = '/engineering/v1/license';
14
+ export const LICENSE_TIMEOUT_MS = 5_000;
15
+ const MAX_LICENSE_BODY_BYTES = 16 * 1024;
16
+ const MAX_URL_LENGTH = 2_048;
17
+ const ISO_INSTANT = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?Z$/u;
18
+ // Reasons are displayed, so only a plain identifier is accepted. New server
19
+ // reasons stay readable by old clients without an allowlist update.
20
+ const SAFE_REASON = /^[a-z][a-z0-9_]{0,63}$/u;
21
+ /** @type {ReadonlyMap<number, string>} */
22
+ const UNAVAILABLE_BY_STATUS = new Map([
23
+ [401, 'unauthorized'],
24
+ [404, 'not_offered'],
25
+ [429, 'rate_limited'],
26
+ [503, 'license_unavailable'],
27
+ ]);
28
+ const SERVER_ERROR_STATUS = 500;
29
+
30
+ /**
31
+ * @typedef {{
32
+ * state: 'active' | 'inactive' | 'unavailable',
33
+ * reason: string,
34
+ * validUntil: string | null,
35
+ * purchaseUrl: string | null
36
+ * }} EngineeringLicense
37
+ */
38
+
39
+ /** @param {string} reason @returns {EngineeringLicense} */
40
+ export function unavailableLicense(reason) {
41
+ return { state: 'unavailable', reason, validUntil: null, purchaseUrl: null };
42
+ }
43
+
44
+ /** @param {string} endpoint */
45
+ export function engineeringLicenseUrl(endpoint) {
46
+ return new URL(LICENSE_PATH, endpoint).toString();
47
+ }
48
+
49
+ /** @param {unknown} value @returns {value is Record<string, unknown>} */
50
+ export function isRecord(value) {
51
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
52
+ }
53
+
54
+ /** @param {unknown} value @returns {value is string} */
55
+ export function isPurchaseUrl(value) {
56
+ if (typeof value !== 'string' || value.length > MAX_URL_LENGTH) return false;
57
+ try {
58
+ const url = new URL(value);
59
+ return url.protocol === 'https:' && url.username === '' && url.password === '';
60
+ } catch {
61
+ return false;
62
+ }
63
+ }
64
+
65
+ /** @param {unknown} value */
66
+ function isValidUntil(value) {
67
+ return (
68
+ value === null ||
69
+ (typeof value === 'string' && ISO_INSTANT.test(value) && Number.isFinite(Date.parse(value)))
70
+ );
71
+ }
72
+
73
+ /**
74
+ * Validate the 200 body of the license route (OpenAPI `EngineeringLicense`).
75
+ * Unknown extra fields are ignored; anything malformed rejects the whole body.
76
+ * @param {string} text
77
+ * @returns {EngineeringLicense | null}
78
+ */
79
+ export function parseLicenseBody(text) {
80
+ let body;
81
+ try {
82
+ body = JSON.parse(text);
83
+ } catch {
84
+ return null;
85
+ }
86
+ if (!isRecord(body) || body.schema_version !== 1 || body.capability !== 'engineering')
87
+ return null;
88
+ const { state, reason, valid_until: validUntil, purchase_url: purchaseUrl } = body;
89
+ if (state !== 'active' && state !== 'inactive') return null;
90
+ if (typeof reason !== 'string' || !SAFE_REASON.test(reason)) return null;
91
+ if (!isValidUntil(validUntil) || !isPurchaseUrl(purchaseUrl)) return null;
92
+ return {
93
+ state,
94
+ reason,
95
+ validUntil: /** @type {string | null} */ (validUntil),
96
+ purchaseUrl: /** @type {string} */ (purchaseUrl),
97
+ };
98
+ }
99
+
100
+ /**
101
+ * Read a response body as UTF-8, refusing anything above `limit` bytes.
102
+ * @param {Response} response
103
+ * @param {number} [limit]
104
+ */
105
+ export async function readBoundedText(response, limit = MAX_LICENSE_BODY_BYTES) {
106
+ const declared = Number(response.headers.get('content-length') ?? '0');
107
+ if (declared > limit) throw new Error('body_too_large');
108
+ if (response.body === null) return '';
109
+ const reader = response.body.getReader();
110
+ /** @type {Uint8Array[]} */
111
+ const chunks = [];
112
+ let size = 0;
113
+ for (;;) {
114
+ const { done, value } = await reader.read();
115
+ if (done) break;
116
+ size += value.byteLength;
117
+ if (size > limit) {
118
+ await reader.cancel().catch(() => undefined);
119
+ throw new Error('body_too_large');
120
+ }
121
+ chunks.push(value);
122
+ }
123
+ return Buffer.concat(chunks).toString('utf8');
124
+ }
125
+
126
+ /** @param {unknown} error */
127
+ export function isTimeout(error) {
128
+ return error instanceof Error && (error.name === 'TimeoutError' || error.name === 'AbortError');
129
+ }
130
+
131
+ /** @param {number} status */
132
+ export function unavailableReasonFor(status) {
133
+ return (
134
+ UNAVAILABLE_BY_STATUS.get(status) ??
135
+ (status >= SERVER_ERROR_STATUS ? 'server_error' : 'unexpected_status')
136
+ );
137
+ }
138
+
139
+ /**
140
+ * Ask the server whether this account holds the AISAC Engineering license.
141
+ * @param {string} endpoint configured AI Brain MCP endpoint (already validated at setup)
142
+ * @param {string} token account PAT
143
+ * @param {{ fetchImpl?: typeof fetch, timeoutMs?: number }} [options]
144
+ * @returns {Promise<EngineeringLicense>}
145
+ */
146
+ export async function checkEngineeringLicense(endpoint, token, options = {}) {
147
+ const { fetchImpl = fetch, timeoutMs = LICENSE_TIMEOUT_MS } = options;
148
+ let url;
149
+ try {
150
+ url = engineeringLicenseUrl(endpoint);
151
+ } catch {
152
+ return unavailableLicense('invalid_endpoint');
153
+ }
154
+ const signal = AbortSignal.timeout(timeoutMs);
155
+ let response;
156
+ try {
157
+ response = await fetchImpl(url, {
158
+ method: 'GET',
159
+ redirect: 'error',
160
+ signal,
161
+ headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' },
162
+ });
163
+ } catch (error) {
164
+ return unavailableLicense(isTimeout(error) ? 'timeout' : 'unreachable');
165
+ }
166
+ if (response.status !== 200) {
167
+ await response.body?.cancel().catch(() => undefined);
168
+ return unavailableLicense(unavailableReasonFor(response.status));
169
+ }
170
+ let text;
171
+ try {
172
+ text = await readBoundedText(response);
173
+ } catch (error) {
174
+ return unavailableLicense(isTimeout(error) ? 'timeout' : 'invalid_response');
175
+ }
176
+ return parseLicenseBody(text) ?? unavailableLicense('invalid_response');
177
+ }
178
+
179
+ /**
180
+ * One line for `aisac setup`. Returns null while the server does not offer the
181
+ * capability yet, so memory-only users see no new noise.
182
+ * @param {EngineeringLicense} license
183
+ * @returns {string | null}
184
+ */
185
+ export function licenseLine(license) {
186
+ if (license.state === 'active') {
187
+ const until = license.validUntil === null ? '' : ` until ${license.validUntil.slice(0, 10)}`;
188
+ return `AISAC Engineering: active${until}.`;
189
+ }
190
+ if (license.state === 'inactive')
191
+ return `AISAC Engineering: inactive (${license.reason}) — ${license.purchaseUrl ?? ''}`.trimEnd();
192
+ if (license.reason === 'not_offered') return null;
193
+ return `AISAC Engineering: unavailable (${license.reason}); memory and context are unaffected.`;
194
+ }
package/dist/launch.mjs CHANGED
@@ -4,13 +4,20 @@ import { access, chmod, lstat, mkdir, mkdtemp, open, rm, writeFile } from 'node:
4
4
  import { spawn } from 'node:child_process';
5
5
  import { constants } from 'node:fs';
6
6
  import { isAbsolute, join, resolve, sep } from 'node:path';
7
- import { assertTestedNativeVersion, nativeVersion, resolveNative } from './platform.mjs';
7
+ import { captureLockPlatformCheck, nativeVersion, resolveNative } from './platform.mjs';
8
+ import { claudeCompatibilityError, resolveClaudeCompatibility } from './claude-compatibility.mjs';
9
+ import { codexCompatibilityError, resolveCodexCompatibility } from './codex-compatibility.mjs';
8
10
  import {
9
11
  claudeCheckpointConfigBinding,
10
12
  claudeObservationConfigBinding,
11
13
  telemetryConfigBinding,
12
14
  } from './state.mjs';
13
15
  import { codexPolicyJson, makePolicy } from './policy.mjs';
16
+ import {
17
+ prepareEngineeringPack,
18
+ sweepStaleLaunches,
19
+ writeLaunchMarker,
20
+ } from './engineering-pack.mjs';
14
21
  import {
15
22
  CODEX_LAUNCH_ENVELOPE,
16
23
  CODEX_MONITOR_CWD,
@@ -359,14 +366,26 @@ async function assertClaudeCheckpointHelper(packageRoot) {
359
366
  return helperPath;
360
367
  }
361
368
 
362
- /** @param {string} root @param {string} prefix */
369
+ /**
370
+ * A fresh private directory for one launch. Directories left by launches whose
371
+ * processes are gone are swept first; this one records its launcher.
372
+ * @param {string} root @param {string} prefix
373
+ */
363
374
  async function launchDirectory(root, prefix) {
375
+ await sweepStaleLaunches(root);
364
376
  const parent = await privateDirectory(join(root, 'runtime'));
365
377
  const directory = await mkdtemp(join(parent, `${prefix}-`));
366
378
  await chmod(directory, 0o700);
379
+ await writeLaunchMarker(directory, { launcherPid: process.pid, childPid: null });
367
380
  return directory;
368
381
  }
369
382
 
383
+ /** @param {string} directory @param {number | undefined} childPid */
384
+ async function recordChild(directory, childPid) {
385
+ if (childPid === undefined) return;
386
+ await writeLaunchMarker(directory, { launcherPid: process.pid, childPid }).catch(() => undefined);
387
+ }
388
+
370
389
  /** @param {import('node:child_process').ChildProcess} child @param {string} directory */
371
390
  function waitForChild(child, directory) {
372
391
  return new Promise((resolvePromise) => {
@@ -399,15 +418,32 @@ function waitForChild(child, directory) {
399
418
  });
400
419
  }
401
420
 
402
- /** @param {LaunchConfig} config @param {string} root @param {string[]} args */
403
- export async function launchClaude(config, root, args) {
421
+ /**
422
+ * @typedef {object} LaunchDependencies
423
+ * @property {typeof prepareEngineeringPack} [preparePack] AISAC Engineering pack loader (E4b)
424
+ */
425
+
426
+ /**
427
+ * @param {LaunchConfig} config
428
+ * @param {string} root
429
+ * @param {string[]} args
430
+ * @param {LaunchDependencies} [dependencies]
431
+ */
432
+ export async function launchClaude(config, root, args, dependencies = {}) {
433
+ const preparePack = dependencies.preparePack ?? prepareEngineeringPack;
404
434
  const validated = assertConfig(config);
405
435
  const launchArgs = await parseClaudeLaunchArgs(args, process.cwd());
406
436
  assertClaudeArgs(launchArgs.args);
407
437
  const executable = resolveNative('claude');
408
438
  if (!executable) throw new Error('claude_not_found');
409
- const currentVersion = nativeVersion(executable);
410
- assertTestedNativeVersion('claude', currentVersion);
439
+ const compatibility = await resolveClaudeCompatibility(executable, validated.packageRoot);
440
+ if (compatibility.status === 'rejected') throw new Error(claudeCompatibilityError(compatibility));
441
+ const pinnedExecutable = compatibility.executable ?? executable;
442
+ if (compatibility.status === 'host_checked')
443
+ process.stderr.write(
444
+ `aisac: Claude ${compatibility.version ?? 'unknown'} accepted by the local native host contract; end-to-end recovery remains separately qualified.\n`,
445
+ );
446
+ const currentVersion = compatibility.version ?? nativeVersion(pinnedExecutable);
411
447
  const pluginRoot = await assertClaudeAssets(validated.packageRoot);
412
448
  const telemetryActive =
413
449
  validated.telemetry?.enabled === true && validated.telemetry?.disclosedAt != null;
@@ -526,13 +562,22 @@ export async function launchClaude(config, root, args) {
526
562
  await rm(directory, { recursive: true, force: true });
527
563
  throw error;
528
564
  }
565
+ // The pack never blocks the launch: every failure starts Claude without it.
566
+ const engineering = await preparePack({
567
+ endpoint: validated.endpoint,
568
+ token: validated.token,
569
+ root,
570
+ directory,
571
+ packageRoot: validated.packageRoot,
572
+ }).catch(() => ({ pluginRoot: null, line: null }));
573
+ if (engineering.line !== null) process.stderr.write(`${engineering.line}\n`);
529
574
  const environment = childEnvironment(root);
530
575
  environment.CLAUDE_CODE_ENABLE_FUNCTION_HOOKS = '1';
531
576
  /** @type {import('node:child_process').ChildProcess} */
532
577
  let child;
533
578
  try {
534
579
  child = spawn(
535
- executable,
580
+ pinnedExecutable,
536
581
  [
537
582
  '--settings',
538
583
  settingsPath,
@@ -542,6 +587,7 @@ export async function launchClaude(config, root, args) {
542
587
  requiredAllow.join(','),
543
588
  '--plugin-dir',
544
589
  pluginRoot,
590
+ ...(engineering.pluginRoot === null ? [] : ['--plugin-dir', engineering.pluginRoot]),
545
591
  ...launchArgs.args,
546
592
  ],
547
593
  { cwd: process.cwd(), env: environment, stdio: 'inherit' },
@@ -550,20 +596,28 @@ export async function launchClaude(config, root, args) {
550
596
  await rm(directory, { recursive: true, force: true }).catch(() => undefined);
551
597
  throw error;
552
598
  }
553
- return waitForChild(child, directory);
599
+ // Listen for exit before any await, so a child that exits at once is not missed.
600
+ const finished = waitForChild(child, directory);
601
+ await recordChild(directory, child.pid);
602
+ return finished;
554
603
  }
555
604
 
556
605
  /** @param {LaunchConfig} config @param {string} root @param {string[]} args */
557
606
  export async function launchCodex(config, root, args) {
558
607
  const validated = assertConfig(config);
559
608
  assertCodexArgs(args);
609
+ const captureLockPlatform = captureLockPlatformCheck();
610
+ if (!captureLockPlatform.ok) throw new Error(captureLockPlatform.code);
560
611
  const executable = resolveNative('codex');
561
612
  const node = resolveNative('node');
562
613
  if (!executable || !node) throw new Error('codex_or_node_not_found');
563
- const currentVersion = nativeVersion(executable);
564
- assertTestedNativeVersion('codex', currentVersion);
565
- const currentSemver = currentVersion.match(/\b\d+\.\d+\.\d+\b/u)?.[0];
566
- if (currentSemver === undefined) throw new Error('codex_version_unverified');
614
+ const compatibility = await resolveCodexCompatibility(executable);
615
+ if (compatibility.status === 'rejected') throw new Error(codexCompatibilityError(compatibility));
616
+ if (compatibility.version === null) throw new Error('codex_version_unverified');
617
+ if (compatibility.status === 'protocol_match')
618
+ process.stderr.write(
619
+ `aisac: Codex ${compatibility.version} matched the local protocol contract; native behavioral qualification is not established.\n`,
620
+ );
567
621
  const launchPlan = await parseCodexLaunchArgs(args, process.cwd());
568
622
  const packageRoot = resolve(validated.packageRoot);
569
623
  const terminal = join(packageRoot, 'assets', 'jev-paid-pilot-terminal.mjs');
@@ -613,7 +667,7 @@ export async function launchCodex(config, root, args) {
613
667
  environment.CODEX_CONTEXT_DIAGNOSTICS_ENABLED = '1';
614
668
  environment.AIBRAIN_MCP_URL = validated.endpoint;
615
669
  environment.AIBRAIN_MCP_TOKEN = validated.token;
616
- environment.CODEX_CLIENT_VERSION = currentSemver;
670
+ environment.CODEX_CLIENT_VERSION = compatibility.version;
617
671
  environment.CODEX_CONTEXT_TELEMETRY_ENABLED = validated.telemetry?.enabled === true ? '1' : '0';
618
672
  environment.CODEX_CONTEXT_TELEMETRY_DISCLOSED =
619
673
  validated.telemetry?.enabled === true && validated.telemetry?.disclosedAt != null ? '1' : '0';
@@ -627,7 +681,10 @@ export async function launchCodex(config, root, args) {
627
681
  env: environment,
628
682
  stdio: 'inherit',
629
683
  });
630
- return waitForChild(child, directory);
684
+ // Listen for exit before any await, so a child that exits at once is not missed.
685
+ const finished = waitForChild(child, directory);
686
+ await recordChild(directory, child.pid);
687
+ return finished;
631
688
  }
632
689
 
633
690
  export const launchInternals = { MCP_ALIAS, PLUGIN_NAME, PLUGIN_INLINE_NAME };
package/dist/mcp.mjs CHANGED
@@ -252,7 +252,7 @@ export async function reportContextTelemetry(endpoint, token, report, deadlineMs
252
252
  params: {
253
253
  protocolVersion: '2025-03-26',
254
254
  capabilities: {},
255
- clientInfo: { name: 'aibrain-telemetry-report', version: '0.3.0-beta.7' },
255
+ clientInfo: { name: 'aibrain-telemetry-report', version: '0.3.0-beta.9' },
256
256
  },
257
257
  },
258
258
  undefined,
@@ -344,7 +344,7 @@ export async function selectContextShadow(endpoint, token, request, deadlineMs =
344
344
  params: {
345
345
  protocolVersion: '2025-03-26',
346
346
  capabilities: {},
347
- clientInfo: { name: 'aibrain-claude-shadow', version: '0.3.0-beta.7' },
347
+ clientInfo: { name: 'aibrain-claude-shadow', version: '0.3.0-beta.9' },
348
348
  },
349
349
  },
350
350
  undefined,
@@ -428,7 +428,7 @@ export async function readContextBudget(endpoint, token, deadlineMs = 8_000) {
428
428
  params: {
429
429
  protocolVersion: '2025-03-26',
430
430
  capabilities: {},
431
- clientInfo: { name: 'aibrain-context-budget', version: '0.3.0-beta.7' },
431
+ clientInfo: { name: 'aibrain-context-budget', version: '0.3.0-beta.9' },
432
432
  },
433
433
  },
434
434
  undefined,
@@ -578,7 +578,7 @@ async function providerFreeProbeV1(endpoint, token) {
578
578
  params: {
579
579
  protocolVersion: '2025-03-26',
580
580
  capabilities: {},
581
- clientInfo: { name: 'aibrain', version: '0.3.0-beta.7' },
581
+ clientInfo: { name: 'aibrain', version: '0.3.0-beta.9' },
582
582
  },
583
583
  },
584
584
  undefined,
@@ -632,7 +632,7 @@ async function providerFreeProbeV1(endpoint, token) {
632
632
  arguments: {
633
633
  schema_version: 1,
634
634
  client: 'codex_app_server',
635
- client_version: '0.3.0-beta.7',
635
+ client_version: '0.3.0-beta.9',
636
636
  session_ref: sessionRef,
637
637
  revision,
638
638
  observed_at: new Date().toISOString(),
@@ -724,7 +724,7 @@ export async function providerFreeProbeV2(endpoint, token, policy, clients) {
724
724
  params: {
725
725
  protocolVersion: '2025-03-26',
726
726
  capabilities: {},
727
- clientInfo: { name: 'aibrain', version: '0.3.0-beta.7' },
727
+ clientInfo: { name: 'aibrain', version: '0.3.0-beta.9' },
728
728
  },
729
729
  },
730
730
  undefined,
@@ -928,7 +928,7 @@ export async function providerFreeProbeV2(endpoint, token, policy, clients) {
928
928
  await callTool(2, 'brain_context_select', {
929
929
  schema_version: 2,
930
930
  client: clients === 'claude' ? 'claude_code' : 'codex_app_server',
931
- client_version: '0.3.0-beta.7',
931
+ client_version: '0.3.0-beta.9',
932
932
  session_ref: sessionRef,
933
933
  revision,
934
934
  observed_at: now,
@@ -941,7 +941,7 @@ export async function providerFreeProbeV2(endpoint, token, policy, clients) {
941
941
  await callTool(3, 'brain_context_compact', {
942
942
  schema_version: 2,
943
943
  client: 'claude_code',
944
- client_version: '0.3.0-beta.7',
944
+ client_version: '0.3.0-beta.9',
945
945
  session_ref: sessionRef,
946
946
  revision,
947
947
  observed_at: now,
package/dist/package.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "name": "@brunobracaioli/aibrain",
3
- "version": "0.3.0-beta.7",
3
+ "version": "0.4.0-beta.2",
4
4
  "type": "module"
5
5
  }
package/dist/platform.mjs CHANGED
@@ -23,6 +23,19 @@ export function platformCheck() {
23
23
  return { ok: true };
24
24
  }
25
25
 
26
+ /** @param {NodeJS.ProcessEnv} [environment] */
27
+ export function captureLockPlatformCheck(environment = process.env) {
28
+ const platform = platformCheck();
29
+ if (!platform.ok) return platform;
30
+ if (resolveNative('flock', environment) === null)
31
+ return {
32
+ ok: false,
33
+ code: 'CODEX_CAPTURE_LOCK_BACKEND_UNAVAILABLE',
34
+ message: 'Codex capture requires util-linux flock on Linux/WSL.',
35
+ };
36
+ return { ok: true };
37
+ }
38
+
26
39
  /** @param {string} name @param {NodeJS.ProcessEnv} [environment] */
27
40
  export function resolveNative(name, environment = process.env) {
28
41
  if (!/^[A-Za-z0-9._+-]+$/u.test(name)) return null;
@@ -70,13 +83,44 @@ export function nativeVersion(executable, environment = process.env) {
70
83
  export function isTestedNativeVersion(client, version) {
71
84
  const value = version.trim();
72
85
  if (client === 'claude')
73
- return /^(?:(?:claude(?:\s+code)?)\s+)?2\.1\.(?:294|295|296|299|300)(?:\s+\(Claude\s+Code\))?$/iu.test(
86
+ return /^(?:(?:claude(?:\s+code)?)\s+)?2\.1\.(?:294|295|296|299|300|301|302|303)(?:\s+\(Claude\s+Code\))?$/iu.test(
74
87
  value,
75
88
  );
76
- return /^(?:codex(?:-cli)?\s+)?0\.16(?:1\.0|2\.0|2\.1)$/iu.test(value);
89
+ return /^(?:codex(?:-cli)?\s+)?0\.16(?:1\.0|2\.0|2\.1|3\.0|4\.0)$/iu.test(value);
90
+ }
91
+
92
+ const TESTED_NATIVE_VERSIONS = Object.freeze({
93
+ claude: Object.freeze([
94
+ '2.1.294',
95
+ '2.1.295',
96
+ '2.1.296',
97
+ '2.1.299',
98
+ '2.1.300',
99
+ '2.1.301',
100
+ '2.1.302',
101
+ '2.1.303',
102
+ ]),
103
+ codex: Object.freeze(['0.161.0', '0.162.0', '0.162.1', '0.163.0', '0.164.0']),
104
+ });
105
+
106
+ /** @param {'claude'|'codex'} client @param {string} version */
107
+ export function nativeVersionDiagnostic(client, version) {
108
+ const detected = version.trim();
109
+ const versionPattern =
110
+ client === 'claude'
111
+ ? /^(?:(?:claude(?:\s+code)?)\s+)?(\d{1,3})\.(\d{1,3})\.(\d{1,3})(-[A-Za-z0-9.-]+)?(?:\s+\(Claude\s+Code\))?$/iu
112
+ : /^(?:codex(?:-cli)?\s+)?(\d{1,3})\.(\d{1,3})\.(\d{1,3})(-[A-Za-z0-9.-]+)?$/iu;
113
+ const versionMatch = detected.match(versionPattern);
114
+ const safeDetected = versionMatch
115
+ ? `${versionMatch[1]}.${versionMatch[2]}.${versionMatch[3]}${versionMatch[4] ? '-prerelease' : ''}`
116
+ : 'unavailable';
117
+ const label = client === 'claude' ? 'Claude Code' : 'Codex CLI';
118
+ const tested = TESTED_NATIVE_VERSIONS[client].join(', ');
119
+ return `${client}_version_unverified: ${label} detected version ${safeDetected}; tested stable versions: ${tested}. Update AI Brain to a compatible version, then run aisac doctor --json or install a qualified stable native version.`;
77
120
  }
78
121
 
79
122
  /** @param {'claude'|'codex'} client @param {string} version */
80
123
  export function assertTestedNativeVersion(client, version) {
81
- if (!isTestedNativeVersion(client, version)) throw new Error(`${client}_version_unverified`);
124
+ if (!isTestedNativeVersion(client, version))
125
+ throw new Error(nativeVersionDiagnostic(client, version));
82
126
  }
package/package.json CHANGED
@@ -1,11 +1,12 @@
1
1
  {
2
2
  "name": "@brunobracaioli/aibrain",
3
- "version": "0.3.0-beta.7",
3
+ "version": "0.4.0-beta.2",
4
4
  "private": false,
5
- "description": "AI Brain assisted context beta client for Claude Code and Codex",
5
+ "description": "AISAC client for Claude Code and Codex: AI Brain memory, context management and AISAC Engineering",
6
6
  "license": "UNLICENSED",
7
7
  "type": "module",
8
8
  "bin": {
9
+ "aisac": "./dist/cli.mjs",
9
10
  "aibrain": "./dist/cli.mjs"
10
11
  },
11
12
  "files": [