proteum 2.5.22 → 2.5.23

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/AGENTS.md CHANGED
@@ -65,7 +65,7 @@ npx prisma migrate dev --config ./prisma.config.ts --name <migration name>
65
65
  - Keep the developer-facing contract synchronized when framework work changes CLI commands, profiler capabilities, or the `proteum dev` banner. Update the live surfaces together in the same pass: CLI command/help definitions, profiler panels and dev-only endpoints, banner text/examples, and the most relevant agent docs that describe them, especially `AGENTS.md`, `agents/project/AGENTS.md`, `agents/project/diagnostics.md`, and any narrower `agents/project/**/AGENTS.md` file that mentions the changed workflow.
66
66
  - Proteum MCP contract: `proteum mcp` is the machine-scope router agents register once, and `proteum dev` exposes each app runtime at `/__proteum/mcp`. `proteum dev` ensures one managed machine MCP daemon is running; do not start a second managed daemon. Agents should start with MCP `workflow_start` using `cwd` or a known `projectId`; ambiguous routing or offline app candidates use `project_resolve { cwd }`, and follow-up live app tools require the returned `projectId`. Dev-hosted app tools are already rooted to their own runtime. Keep MCP tools/resources compact, typed, capped, paginated for full trace detail, and read-only unless a future task explicitly expands the mutation contract. The database diagnostic exception is still read-only: MCP `db_query` and CLI `proteum db query` allow one capped `SELECT`, `SHOW`, or `EXPLAIN` statement only and return rows plus elapsed milliseconds. MCP payloads are compact single-line `proteum-mcp-v1` JSON, not pretty-printed human output. Do not implement MCP tools as thin CLI process wrappers when the data is available through manifest readers, tracked sessions, or dev runtime registries.
67
67
  - Keep the same-system trace contract explicit when request instrumentation changes: `TRACE_*` controls the retained dev trace store plus the trace/perf CLI, dev-only HTTP endpoints, and bottom profiler, while `ENABLE_PROFILER` enables the reduced request-local `request.profiling` snapshot and `request.finished` hook payload without retaining finished requests globally unless dev trace is also enabled.
68
- - Current CLI banner contract: only the bare `proteum build` and bare `proteum dev` commands print the welcome banner and include the active Proteum installation method. Any extra argument or option skips the welcome banner. Terminal `proteum mcp` may print a compact central MCP ready banner when it starts or reuses the managed daemon. Only `proteum dev` clears the interactive terminal before rendering, exposes `CTRL+R` reload plus `CTRL+C` shutdown hotkeys in its session UI, and reports connected app names plus successful connected `/ping` checks in the ready banner. Every `proteum dev` start ensures tracked instruction files contain the current managed `# Proteum Instructions` section and `CLAUDE.md` symlinks point to sibling `AGENTS.md` files before the dev loop begins.
68
+ - Current CLI banner contract: only the bare `proteum build` and bare `proteum dev` commands print the welcome banner and include the active Proteum installation method. Any extra argument or option skips the welcome banner. Terminal `proteum mcp` may print a compact central MCP ready banner when it starts or reuses the managed daemon. Only `proteum dev` clears the interactive terminal before rendering, exposes `CTRL+R` reload plus `CTRL+C` shutdown hotkeys in its session UI, and reports connected app names plus successful connected `/ping` checks in the ready banner. Every `proteum dev` start ensures tracked instruction files contain the current managed `# Proteum Instructions` section and `CLAUDE.md` symlinks point to sibling `AGENTS.md` files before the dev loop begins, except for apps whose `proteum.config.ts` sets `agentInstructions: false` (hand-owned instructions; a monorepo root is managed only when no app opted out).
69
69
  - Keep core changes aligned with the explicit controller/page architecture in `agents/project/AGENTS.md`.
70
70
  - Prefer removing framework magic when the same result can be expressed with explicit contracts, generated code, or typed context.
71
71
  - Apply the pruning rules from `agents/project/optimizations.md`, especially for webpack plugins, Babel plugins, aliases, helpers, runtime services, and npm packages that are not meaningfully used by both apps.
package/README.md CHANGED
@@ -206,7 +206,7 @@ An agent — or you — can ask the framework directly:
206
206
 
207
207
  - **One MCP entry point.** `proteum mcp` runs a machine-scope router; `proteum dev` exposes each app at `/__proteum/mcp`. An agent calls `workflow_start`, gets a stable `projectId`, and routes every follow-up read to the right app.
208
208
  - **Token-efficient output.** Diagnostics default to compact `proteum-agent-v1` JSON — decision-ready summaries first, raw detail only behind `--full`, `--manifest`, or `--events`.
209
- - **Generated instruction files.** `proteum configure agents` writes managed `AGENTS.md` / `CLAUDE.md` instruction routers, kept in sync on every `proteum dev` start.
209
+ - **Generated instruction files.** `proteum configure agents` writes managed `AGENTS.md` / `CLAUDE.md` instruction routers, kept in sync on every `proteum dev` start. Projects that write their own instructions set `agentInstructions: false` in `proteum.config.ts` and Proteum leaves them alone.
210
210
  - **Auth without UI automation.** `proteum session <email> --role ADMIN` mints a dev session (token + Playwright-ready cookie) so agents and E2E suites skip the login flow.
211
211
 
212
212
  > Full agent contract: [docs/mcp.md](docs/mcp.md), [docs/diagnostics.md](docs/diagnostics.md), and [docs/agent-routing.md](docs/agent-routing.md).
@@ -18,6 +18,7 @@ import {
18
18
  configureProjectAgentInstructions,
19
19
  findLikelyRepoRoot,
20
20
  isInsideDirectory,
21
+ isProjectAgentInstructionsEnabled,
21
22
  resolveCanonicalPath,
22
23
  type TConfigureMonorepoProjectAgentInstructionsResult,
23
24
  type TConfigureProjectAgentInstructionsResult,
@@ -143,6 +144,9 @@ const renderConfigureMonorepoResultSections = (result: TConfigureMonorepoProject
143
144
  appRoot: result.monorepoRoot,
144
145
  });
145
146
 
147
+ const agentInstructionsDisabledMessage = (root: string) =>
148
+ `Agent instructions are hand-owned in ${root}: \`agentInstructions: false\` is set in proteum.config.ts, so \`proteum configure agents\` does nothing. Remove that setting to let Proteum manage them again.`;
149
+
146
150
  /*----------------------------------
147
151
  - COMMAND
148
152
  ----------------------------------*/
@@ -162,6 +166,8 @@ export const runConfigureAgentsWizard = async ({
162
166
  } = {}) => {
163
167
  assertProteumAppRoot(appRoot);
164
168
 
169
+ if (!isProjectAgentInstructionsEnabled(appRoot)) throw new UsageError(agentInstructionsDisabledMessage(appRoot));
170
+
165
171
  if (!process.stdin.isTTY || !process.stdout.isTTY) {
166
172
  throw new UsageError('`proteum configure agents` is interactive and requires a TTY.');
167
173
  }
@@ -241,6 +247,10 @@ export const runConfigureAgentsMonorepoWizard = async ({
241
247
  if (appRoots.length === 0) throw new UsageError(`No Proteum app roots were found under ${monorepoRoot}.`);
242
248
  for (const appRoot of appRoots) assertProteumAppRoot(appRoot);
243
249
 
250
+ if (appRoots.every((appRoot) => !isProjectAgentInstructionsEnabled(appRoot))) {
251
+ throw new UsageError(agentInstructionsDisabledMessage(monorepoRoot));
252
+ }
253
+
244
254
  if (!process.stdin.isTTY || !process.stdout.isTTY) {
245
255
  throw new UsageError('`proteum configure agents` is interactive and requires a TTY.');
246
256
  }
@@ -199,6 +199,9 @@ const ensureProjectAgentInstructions = async () => {
199
199
  dryRun: true,
200
200
  monorepoRoot,
201
201
  });
202
+ // `agentInstructions: false`: the project owns its instruction files, so dev must not rewrite them.
203
+ if (preview.disabled) return;
204
+
202
205
  const overwriteBlockedPaths = await promptBlockedAgentInstructionOverwrites(preview.blocked);
203
206
 
204
207
  const result = configureProjectAgentInstructions({
@@ -331,6 +331,7 @@ export const writeCurrentProteumManifest = ({
331
331
  ]),
332
332
  )
333
333
  : undefined,
334
+ ...(app.setup.agentInstructions === false ? { agentInstructions: false } : {}),
334
335
  },
335
336
  },
336
337
  conventions: {
package/cli/mcp/router.ts CHANGED
@@ -295,13 +295,47 @@ export const createProteumMachineMcpServer = ({ createDevMcpClient, version }: T
295
295
  return client;
296
296
  };
297
297
 
298
- const closeClient = async (record: TMachineDevSessionRecord) => {
298
+ // Removes a client only if the cache still holds that same instance, so a failing call cannot
299
+ // close the fresh client that a parallel call just opened after a dev server restart.
300
+ const evictClient = async (record: TMachineDevSessionRecord, client: TDevMcpClient) => {
299
301
  const key = cacheKey(record);
300
- const client = clients.get(key);
301
- clients.delete(key);
302
- if (client) await client.close().catch(() => undefined);
302
+ if (clients.get(key) === client) clients.delete(key);
303
+ await client.close().catch(() => undefined);
303
304
  };
304
305
 
306
+ /**
307
+ * Calls a tool on the app's dev MCP, retrying once with a fresh client when the cached one fails.
308
+ * A dev server restart drops its MCP sessions, and the cached client then gets "initialize the
309
+ * Proteum MCP session" on its next call. Only a cached client can be stale; a brand-new client
310
+ * failing means the app is down or booting, so it is not retried.
311
+ */
312
+ const callDevTool = async (record: TMachineDevSessionRecord, request: { arguments: Record<string, unknown>; name: string }) => {
313
+ const cached = clients.get(cacheKey(record));
314
+ const client = cached || (await getClient(record));
315
+
316
+ try {
317
+ return await client.callTool(request);
318
+ } catch (error) {
319
+ await evictClient(record, client);
320
+ if (!cached) throw error;
321
+
322
+ const freshClient = await getClient(record);
323
+ try {
324
+ return await freshClient.callTool(request);
325
+ } catch (retryError) {
326
+ await evictClient(record, freshClient);
327
+ throw retryError;
328
+ }
329
+ }
330
+ };
331
+
332
+ const devMcpUnreachableResult = (record: TMachineDevSessionRecord, error: unknown) =>
333
+ errorToolResult(`Could not reach Proteum dev MCP for ${record.projectId}.`, {
334
+ error: error instanceof Error ? error.message : String(error),
335
+ mcpUrl: record.mcpUrl,
336
+ projectId: record.projectId,
337
+ });
338
+
305
339
  const closeAllClients = async () => {
306
340
  const cachedClients = [...clients.values()];
307
341
  clients.clear();
@@ -446,23 +480,18 @@ export const createProteumMachineMcpServer = ({ createDevMcpClient, version }: T
446
480
  const resolution = await resolveProject(input.projectId);
447
481
  if (!resolution.record) return resolution.error;
448
482
 
483
+ let result: CallToolResult;
449
484
  try {
450
- const client = await getClient(resolution.record);
451
- const result = await client.callTool({
485
+ result = await callDevTool(resolution.record, {
452
486
  arguments: stripProjectRouting(input),
453
487
  name,
454
488
  });
455
-
456
- if (name === 'runtime_status' || name === 'doctor') return augmentForwardedPayload(result, resolution.record);
457
- return result;
458
489
  } catch (error) {
459
- await closeClient(resolution.record);
460
- return errorToolResult(`Could not reach Proteum dev MCP for ${resolution.record.projectId}.`, {
461
- error: error instanceof Error ? error.message : String(error),
462
- mcpUrl: resolution.record.mcpUrl,
463
- projectId: resolution.record.projectId,
464
- });
490
+ return devMcpUnreachableResult(resolution.record, error);
465
491
  }
492
+
493
+ if (name === 'runtime_status' || name === 'doctor') return augmentForwardedPayload(result, resolution.record);
494
+ return result;
466
495
  };
467
496
 
468
497
  const createOfflineWorkflowStartResult = async (offline: TOfflineProject, input: Record<string, unknown>) => {
@@ -572,13 +601,18 @@ export const createProteumMachineMcpServer = ({ createDevMcpClient, version }: T
572
601
  return jsonToolResult(createWorktreeBootstrapMcpBlockResponse(bootstrapStatus, compactProject(record)), true);
573
602
  }
574
603
 
604
+ let result: CallToolResult;
575
605
  try {
576
- const client = await getClient(record);
577
- const result = await client.callTool({
606
+ result = await callDevTool(record, {
578
607
  arguments: stripProjectRouting(input),
579
608
  name: 'workflow_start',
580
609
  });
610
+ } catch (error) {
611
+ return devMcpUnreachableResult(record, error);
612
+ }
581
613
 
614
+ // Payload and preflight failures are not connection failures: they must not evict a healthy client.
615
+ try {
582
616
  if (result.content[0]?.type !== 'text') return result;
583
617
 
584
618
  const payload = JSON.parse(result.content[0].text);
@@ -620,8 +654,7 @@ export const createProteumMachineMcpServer = ({ createDevMcpClient, version }: T
620
654
  nextActions: dedupeNextActions([...preflight.nextActions, ...(routedNextActions || [])]),
621
655
  });
622
656
  } catch (error) {
623
- await closeClient(record);
624
- return errorToolResult(`Could not reach Proteum dev MCP for ${record.projectId}.`, {
657
+ return errorToolResult(`Could not read the workflow_start payload from ${record.projectId}.`, {
625
658
  error: error instanceof Error ? error.message : String(error),
626
659
  mcpUrl: record.mcpUrl,
627
660
  projectId: record.projectId,
@@ -135,6 +135,7 @@ export const proteumCommands: Record<TProteumCommandName, TProteumCommandDoc> =
135
135
  'Every generated `CLAUDE.md` is a sibling symlink pointing to `AGENTS.md`.',
136
136
  'Every managed instruction file contains a `# Proteum Instructions` section with the full embedded Proteum project instruction corpus.',
137
137
  'Existing content outside `# Proteum Instructions` is preserved. Directories and foreign symlinks are replaced only after confirmation.',
138
+ 'An app with `agentInstructions: false` in `proteum.config.ts` owns its instruction files: configure refuses to run for it, `proteum dev` leaves them alone, and a monorepo root is managed only when no app opted out.',
138
139
  ],
139
140
  status: 'experimental',
140
141
  },
@@ -139,7 +139,7 @@ export const renderCliOverview = async ({
139
139
  indent: ' ',
140
140
  nextIndent: ' ',
141
141
  }),
142
- wrapText('Before the dev loop starts, `proteum dev` ensures tracked instruction files contain the current managed `# Proteum Instructions` section and `CLAUDE.md` symlinks point to sibling `AGENTS.md` files.', {
142
+ wrapText('Before the dev loop starts, `proteum dev` ensures tracked instruction files contain the current managed `# Proteum Instructions` section and `CLAUDE.md` symlinks point to sibling `AGENTS.md` files, unless `proteum.config.ts` sets `agentInstructions: false`, in which case the project owns those files and dev leaves them untouched.', {
143
143
  indent: ' ',
144
144
  nextIndent: ' ',
145
145
  }),
@@ -5,6 +5,7 @@
5
5
  // Npm
6
6
  import fs from 'fs-extra';
7
7
  import path from 'path';
8
+ import { loadApplicationSetupConfig, resolveSetupConfigFilepath } from '../../common/applicationConfigLoader';
8
9
  import { logVerbose } from '../runtime/verbose';
9
10
  import { createStartDevCommand, findProteumAppRootsUnder, readProteumAppRootSummary } from './appRoots';
10
11
 
@@ -55,6 +56,8 @@ type TEnsureInstructionFilesResult = {
55
56
 
56
57
  export type TConfigureProjectAgentInstructionsResult = {
57
58
  appRoot: string;
59
+ /** True when `agentInstructions: false` made Proteum leave every instruction file alone. */
60
+ disabled?: boolean;
58
61
  blocked: string[];
59
62
  created: string[];
60
63
  monorepoRoot?: string;
@@ -142,6 +145,25 @@ const projectInstructionGitignoreBlockEnd = '# End Proteum-managed instruction f
142
145
  - PUBLIC API
143
146
  ----------------------------------*/
144
147
 
148
+ /**
149
+ * Whether Proteum manages the agent instruction files of an app.
150
+ * An app opts out with `agentInstructions: false` in `proteum.config.ts` when it owns
151
+ * its instructions by hand; without a readable config the historical default (managed) applies.
152
+ */
153
+ export function isProjectAgentInstructionsEnabled(appRoot: string) {
154
+ if (!fs.existsSync(resolveSetupConfigFilepath(appRoot))) return true;
155
+
156
+ return loadApplicationSetupConfig(appRoot).agentInstructions !== false;
157
+ }
158
+
159
+ /**
160
+ * Monorepo root files are shared by every app, so Proteum only writes them when no app opted out:
161
+ * one hand-owned app is enough to make the shared root hand-owned too.
162
+ */
163
+ export function isMonorepoAgentInstructionsEnabled(monorepoRoot: string) {
164
+ return findProteumAppRootsUnder(monorepoRoot).every((appRoot) => isProjectAgentInstructionsEnabled(appRoot));
165
+ }
166
+
145
167
  export function configureProjectAgentInstructions({
146
168
  appRoot,
147
169
  coreRoot,
@@ -170,6 +192,19 @@ export function configureProjectAgentInstructions({
170
192
  updated: [],
171
193
  updatedGitignores: [],
172
194
  };
195
+ const manageAppInstructions = includeAppInstructions && isProjectAgentInstructionsEnabled(normalizedAppRoot);
196
+ const manageRootInstructions =
197
+ includeRootInstructions &&
198
+ mode === 'monorepo' &&
199
+ normalizedMonorepoRoot !== undefined &&
200
+ isMonorepoAgentInstructionsEnabled(normalizedMonorepoRoot);
201
+
202
+ // Return before rendering or the dry-run preview: both touch the file system (the preview creates test folders).
203
+ if (!manageAppInstructions && !manageRootInstructions) {
204
+ result.disabled = true;
205
+ return result;
206
+ }
207
+
173
208
  const appEmbeddedInstructions = renderEmbeddedProjectInstructions({
174
209
  appRoot: normalizedAppRoot,
175
210
  coreRoot,
@@ -177,7 +212,7 @@ export function configureProjectAgentInstructions({
177
212
  monorepoRoot: normalizedMonorepoRoot,
178
213
  });
179
214
  const rootEmbeddedInstructions =
180
- mode === 'monorepo'
215
+ mode === 'monorepo' && normalizedMonorepoRoot
181
216
  ? renderEmbeddedProjectInstructions({
182
217
  appRoot: normalizedAppRoot,
183
218
  coreRoot,
@@ -188,7 +223,7 @@ export function configureProjectAgentInstructions({
188
223
  })
189
224
  : appEmbeddedInstructions;
190
225
 
191
- if (includeRootInstructions && mode === 'monorepo' && normalizedMonorepoRoot) {
226
+ if (manageRootInstructions && normalizedMonorepoRoot) {
192
227
  result.monorepoRoot = normalizedMonorepoRoot;
193
228
 
194
229
  const rootInstructions = getRootAgentInstructionDefinitions();
@@ -209,7 +244,7 @@ export function configureProjectAgentInstructions({
209
244
  result.updatedGitignores.push(path.join(normalizedMonorepoRoot, '.gitignore'));
210
245
  }
211
246
 
212
- if (includeAppInstructions) {
247
+ if (manageAppInstructions) {
213
248
  const appInstructions = getAppAgentInstructionDefinitions({ mode });
214
249
  const appFiles = ensureInstructionFiles(
215
250
  normalizedAppRoot,
@@ -30,6 +30,11 @@ export type TApplicationIdentityConfig = {
30
30
  export type TApplicationSetupConfig = {
31
31
  transpile?: string[];
32
32
  connect?: TConnectedProjectsConfig;
33
+ /**
34
+ * Set to `false` when the project owns its agent instruction files by hand.
35
+ * Proteum then never writes AGENTS.md, CLAUDE.md or the routed instruction copies.
36
+ */
37
+ agentInstructions?: boolean;
33
38
  };
34
39
 
35
40
  export type TVerificationCheckScope = 'targeted' | 'area' | 'full' | 'static';
@@ -317,9 +322,14 @@ export const normalizeApplicationSetupConfig = (
317
322
  throw new Error(`Invalid setup config in ${filepath}. Use "transpile" instead of "transpileModules".`);
318
323
  }
319
324
 
325
+ if (value.agentInstructions !== undefined && typeof value.agentInstructions !== 'boolean') {
326
+ throw new Error(`Invalid setup config in ${filepath}. "agentInstructions" must be a boolean.`);
327
+ }
328
+
320
329
  return {
321
330
  transpile: normalizeTranspileConfig(value.transpile),
322
331
  connect: normalizeConnectedProjectsConfig(value.connect),
332
+ ...(value.agentInstructions === undefined ? {} : { agentInstructions: value.agentInstructions }),
323
333
  };
324
334
  };
325
335
 
@@ -780,6 +780,28 @@ const resolveGuidance = ({
780
780
  manifest: TProteumManifest;
781
781
  ownerFilepath?: string;
782
782
  }) => {
783
+ // `agentInstructions: false`: the project owns one hand-written CLAUDE.md and deleted the routed
784
+ // copies, so every guidance slot points at it instead of Proteum's bundled fallbacks.
785
+ if (manifest.app.setup.agentInstructions === false) {
786
+ const claudeInstructions = resolveGuidanceFile({
787
+ appRoot: manifest.app.root,
788
+ fallbackFilepath: joinPath(manifest.app.root, 'CLAUDE.md'),
789
+ relativePath: 'CLAUDE.md',
790
+ }).filepath;
791
+
792
+ return {
793
+ guidance: {
794
+ agents: claudeInstructions,
795
+ documentation: claudeInstructions,
796
+ diagnostics: claudeInstructions,
797
+ optimizations: claudeInstructions,
798
+ codingStyle: claudeInstructions,
799
+ areaAgents: [],
800
+ } satisfies TOrientGuidance,
801
+ warnings: [] as string[],
802
+ };
803
+ }
804
+
783
805
  const fallbackRoot = joinPath(manifest.app.coreRoot, 'agents', 'project');
784
806
  const warnings: string[] = [];
785
807
  const agents = resolveGuidanceFile({
@@ -919,6 +919,15 @@ const createSelectedInstruction = (file: string, reason: string) => ({
919
919
  reason,
920
920
  });
921
921
 
922
+ // This module also runs inside the bundled dev server, where the TypeScript config loader is not
923
+ // available, so the opt-out is read from the config source text instead of evaluating it.
924
+ const readsProteumManagedInstructions = (appRoot: string) => {
925
+ if (fs === undefined || path === undefined) return true;
926
+ const setupFilepath = path.join(appRoot, 'proteum.config.ts');
927
+ if (!fileExists(setupFilepath)) return true;
928
+ return !/\bagentInstructions\s*:\s*false\b/.test(fs.readFileSync(setupFilepath, 'utf8'));
929
+ };
930
+
922
931
  export const resolveInstructionRouting = ({
923
932
  appRoot,
924
933
  query = '',
@@ -930,6 +939,26 @@ export const resolveInstructionRouting = ({
930
939
  const repoRoot = findLikelyRepoRoot(appRoot);
931
940
  const selected = new Map<string, ReturnType<typeof createSelectedInstruction>>();
932
941
  const readWhen: Array<{ file?: string; when: string }> = [];
942
+
943
+ // `agentInstructions: false`: the routed AGENTS.md copies no longer exist; route to the hand-owned CLAUDE.md.
944
+ if (!readsProteumManagedInstructions(appRoot)) {
945
+ const claudeFile = resolveDocumentFile({ appRoot, repoRoot, relativeFilepath: 'CLAUDE.md' });
946
+ if (claudeFile && fileExists(claudeFile)) {
947
+ selected.set(claudeFile, createSelectedInstruction(claudeFile, 'Project-owned agent instructions.'));
948
+ }
949
+ const selectedFiles = [...selected.values()];
950
+ return createMcpPayload({
951
+ summary: `${selectedFiles.length} instruction files selected for ${normalizedQuery || 'current app'}`,
952
+ data: {
953
+ query: normalizedQuery,
954
+ appRoot,
955
+ repoRoot,
956
+ selected: selectedFiles,
957
+ readWhen,
958
+ fullReadPolicy: fullInstructionReadPolicy,
959
+ },
960
+ });
961
+ }
933
962
  const addInstruction = (relativeFilepath: string, reason: string, preferAppRoot = true) => {
934
963
  if (path === undefined) return;
935
964
  const roots = preferAppRoot ? [appRoot, repoRoot] : [repoRoot, appRoot];
@@ -123,6 +123,7 @@ export type TProteumManifest = {
123
123
  setup: {
124
124
  transpile?: string[];
125
125
  connect?: Record<string, { source?: string; urlInternal?: string }>;
126
+ agentInstructions?: boolean;
126
127
  };
127
128
  };
128
129
  conventions: {
@@ -138,3 +138,7 @@ The result confirms the intended routing:
138
138
  - use `workflow_start` to collapse project resolution, fresh-copy readiness, runtime status, instruction previews, owner summary, and first next actions into one read
139
139
  - use machine MCP with `projectId` for repeated runtime reads against an already running app
140
140
  - use `instructions_resolve` to refresh routing instead of rereading full instruction files
141
+
142
+ ## Hand-Owned Instructions
143
+
144
+ A project that writes its own agent instructions sets `agentInstructions: false` in each app's `proteum.config.ts`. Proteum then never writes `AGENTS.md`, `CLAUDE.md` or the routed instruction copies for that app: `proteum dev` skips the sync, `proteum configure agents` refuses to run, and a monorepo root is managed only when no app opted out. MCP `workflow_start` and `instructions_resolve` route such apps to their `CLAUDE.md`, and orientation guidance points there instead of Proteum's bundled fallbacks.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "proteum",
3
3
  "description": "LLM-first Opinionated Typescript Framework for web applications.",
4
- "version": "2.5.22",
4
+ "version": "2.5.23",
5
5
  "author": "Gaetan Le Gac (https://github.com/gaetanlegac)",
6
6
  "repository": "git://github.com/gaetanlegac/proteum.git",
7
7
  "license": "MIT",
@@ -31,5 +31,6 @@ for (const projectRoot of projectRoots) {
31
31
  }
32
32
 
33
33
  console.log(`[update-codex-agents] Syncing project Codex assets in ${projectRoot}`);
34
- configureProjectAgentInstructions({ appRoot: projectRoot, coreRoot: proteumRoot });
34
+ const result = configureProjectAgentInstructions({ appRoot: projectRoot, coreRoot: proteumRoot });
35
+ if (result.disabled) console.warn(`[update-codex-agents] Skipped ${projectRoot}: agentInstructions is false.`);
35
36
  }
@@ -11,6 +11,7 @@ require('ts-node/register/transpile-only');
11
11
  const {
12
12
  configureMonorepoProjectAgentInstructions,
13
13
  configureProjectAgentInstructions,
14
+ isProjectAgentInstructionsEnabled,
14
15
  resolveProjectAgentMonorepoRoot,
15
16
  } = require('../cli/utils/agents.ts');
16
17
 
@@ -581,3 +582,64 @@ test('configure reports blocked paths unless overwrite is allowed', () => {
581
582
  assert.equal(fs.lstatSync(blockedPath).isFile(), true);
582
583
  assert.match(fs.readFileSync(blockedPath, 'utf8'), /## Source: CODING_STYLE\.md/);
583
584
  });
585
+
586
+ const createSetupAppFixture = (appRoot, setupConfig = 'export default {};\n') => {
587
+ fs.mkdirSync(path.join(appRoot, 'client'), { recursive: true });
588
+ fs.mkdirSync(path.join(appRoot, 'server'), { recursive: true });
589
+ writeFile(path.join(appRoot, 'package.json'), '{"name":"fixture"}\n');
590
+ writeFile(path.join(appRoot, 'identity.config.ts'), 'export default {};\n');
591
+ writeFile(path.join(appRoot, 'proteum.config.ts'), setupConfig);
592
+ };
593
+
594
+ test('agentInstructions defaults to managed and reads the opt-out from proteum.config.ts', () => {
595
+ const managedRoot = makeTempRoot();
596
+ const optedOutRoot = makeTempRoot();
597
+ const invalidRoot = makeTempRoot();
598
+
599
+ createSetupAppFixture(managedRoot);
600
+ createSetupAppFixture(optedOutRoot, 'export default { agentInstructions: false };\n');
601
+ createSetupAppFixture(invalidRoot, "export default { agentInstructions: 'no' };\n");
602
+
603
+ assert.equal(isProjectAgentInstructionsEnabled(makeTempRoot()), true);
604
+ assert.equal(isProjectAgentInstructionsEnabled(managedRoot), true);
605
+ assert.equal(isProjectAgentInstructionsEnabled(optedOutRoot), false);
606
+ assert.throws(() => isProjectAgentInstructionsEnabled(invalidRoot), /"agentInstructions" must be a boolean/);
607
+ });
608
+
609
+ test('an opted-out standalone app keeps its hand-owned instruction files untouched', () => {
610
+ const coreRoot = createCoreFixture();
611
+ const appRoot = makeTempRoot();
612
+
613
+ createSetupAppFixture(appRoot, 'export default { agentInstructions: false };\n');
614
+ writeFile(path.join(appRoot, 'CLAUDE.md'), '# Hand-owned\n');
615
+
616
+ const preview = configureProjectAgentInstructions({ appRoot, coreRoot, dryRun: true });
617
+ const result = configureProjectAgentInstructions({ appRoot, coreRoot });
618
+
619
+ assert.equal(preview.disabled, true);
620
+ assert.equal(result.disabled, true);
621
+ assert.deepEqual([result.created, result.updated, result.blocked], [[], [], []]);
622
+ assert.equal(pathEntryExists(path.join(appRoot, 'AGENTS.md')), false);
623
+ assert.equal(pathEntryExists(path.join(appRoot, 'tests')), false);
624
+ assert.equal(fs.readFileSync(path.join(appRoot, 'CLAUDE.md'), 'utf8'), '# Hand-owned\n');
625
+ });
626
+
627
+ test('one opted-out app makes the shared monorepo root hand-owned', () => {
628
+ const coreRoot = createCoreFixture();
629
+ const monorepoRoot = makeTempRoot();
630
+ const productRoot = path.join(monorepoRoot, 'apps', 'product');
631
+ const websiteRoot = path.join(monorepoRoot, 'apps', 'website');
632
+
633
+ fs.mkdirSync(path.join(monorepoRoot, '.git'));
634
+ createSetupAppFixture(productRoot, 'export default { agentInstructions: false };\n');
635
+ createSetupAppFixture(websiteRoot);
636
+
637
+ const productResult = configureProjectAgentInstructions({ appRoot: productRoot, coreRoot, monorepoRoot });
638
+ const websiteResult = configureProjectAgentInstructions({ appRoot: websiteRoot, coreRoot, monorepoRoot });
639
+
640
+ assert.equal(productResult.disabled, true);
641
+ assert.equal(websiteResult.disabled, undefined);
642
+ assert.equal(pathEntryExists(path.join(monorepoRoot, 'AGENTS.md')), false);
643
+ assert.equal(pathEntryExists(path.join(productRoot, 'AGENTS.md')), false);
644
+ assert.equal(pathEntryExists(path.join(websiteRoot, 'AGENTS.md')), true);
645
+ });
@@ -769,6 +769,100 @@ test('machine MCP router forwards app tools without leaking projectId', async (t
769
769
  assert.equal(closeCount, 1);
770
770
  });
771
771
 
772
+ const setupReconnectRouter = async (t, { failFreshClient }) => {
773
+ const previousRegistryDir = process.env.PROTEUM_MACHINE_DEV_SESSION_DIR;
774
+ process.env.PROTEUM_MACHINE_DEV_SESSION_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'proteum-machine-reconnect-'));
775
+ t.onTestFinished(() => {
776
+ if (previousRegistryDir === undefined) delete process.env.PROTEUM_MACHINE_DEV_SESSION_DIR;
777
+ else process.env.PROTEUM_MACHINE_DEV_SESSION_DIR = previousRegistryDir;
778
+ });
779
+
780
+ const appRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'proteum-machine-reconnect-app-'));
781
+ const machineRecord = await writeMachineDevSessionRecord({
782
+ ...createDevSessionRecord({
783
+ appRoot,
784
+ port: 3104,
785
+ sessionFilePath: path.join(appRoot, 'var/run/proteum/dev/3104.json'),
786
+ }),
787
+ publicUrl: 'http://localhost:3104',
788
+ state: 'ready',
789
+ });
790
+ const staleSessionError = new Error(
791
+ 'Streamable HTTP error: Error POSTing to endpoint: Bad Request: initialize the Proteum MCP session before sending tool or resource requests.',
792
+ );
793
+ const createdClients = [];
794
+ const server = createProteumMachineMcpServer({
795
+ createDevMcpClient: async () => {
796
+ const clientIndex = createdClients.length + 1;
797
+ const devClient = {
798
+ calls: 0,
799
+ closeCount: 0,
800
+ callTool: async () => {
801
+ devClient.calls += 1;
802
+ // Client 1 answers once, then the dev server restarts and forgets its session.
803
+ if (clientIndex === 1 && devClient.calls > 1) throw staleSessionError;
804
+ if (clientIndex > 1 && failFreshClient) throw staleSessionError;
805
+ return {
806
+ content: [
807
+ {
808
+ type: 'text',
809
+ text: JSON.stringify({ ok: true, format: 'proteum-mcp-v1', summary: `client ${clientIndex}`, data: {} }),
810
+ },
811
+ ],
812
+ };
813
+ },
814
+ close: async () => {
815
+ devClient.closeCount += 1;
816
+ },
817
+ };
818
+ createdClients.push(devClient);
819
+ return devClient;
820
+ },
821
+ version: 'test',
822
+ });
823
+ const client = new Client({ name: 'machine-mcp-reconnect-test', version: '1.0.0' });
824
+ const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
825
+
826
+ await server.connect(serverTransport);
827
+ await client.connect(clientTransport);
828
+
829
+ const callLogs = async () =>
830
+ await client.callTool({ name: 'logs_tail', arguments: { projectId: machineRecord.projectId } });
831
+
832
+ return { callLogs, client, createdClients, server };
833
+ };
834
+
835
+ test('machine MCP router reconnects once when a dev server restart invalidated the cached session', async (t) => {
836
+ const { callLogs, client, createdClients, server } = await setupReconnectRouter(t, { failFreshClient: false });
837
+
838
+ const first = await callLogs();
839
+ const second = await callLogs();
840
+
841
+ assert.match(first.content[0].text, /client 1/);
842
+ assert.equal(second.isError, undefined);
843
+ assert.match(second.content[0].text, /client 2/);
844
+ assert.equal(createdClients.length, 2);
845
+ assert.equal(createdClients[0].closeCount, 1);
846
+
847
+ await client.close();
848
+ await server.close();
849
+ });
850
+
851
+ test('machine MCP router retries a stale session exactly once before reporting the dev MCP unreachable', async (t) => {
852
+ const { callLogs, client, createdClients, server } = await setupReconnectRouter(t, { failFreshClient: true });
853
+
854
+ await callLogs();
855
+ const second = await callLogs();
856
+
857
+ assert.equal(second.isError, true);
858
+ assert.match(second.content[0].text, /Could not reach Proteum dev MCP/);
859
+ assert.equal(createdClients.length, 2);
860
+ assert.equal(createdClients[1].closeCount, 1);
861
+
862
+ await client.close();
863
+ await server.close();
864
+ });
865
+
772
866
  test('machine MCP router resolves projects by cwd and bootstraps workflow without duplicate discovery', async (t) => {
773
867
  const previousRegistryDir = process.env.PROTEUM_MACHINE_DEV_SESSION_DIR;
774
868
  const registryDir = fs.mkdtempSync(path.join(os.tmpdir(), 'proteum-machine-workflow-router-'));