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 +1 -1
- package/README.md +1 -1
- package/cli/commands/configure.ts +10 -0
- package/cli/commands/dev.ts +3 -0
- package/cli/compiler/artifacts/manifest.ts +1 -0
- package/cli/mcp/router.ts +52 -19
- package/cli/presentation/commands.ts +1 -0
- package/cli/presentation/help.ts +1 -1
- package/cli/utils/agents.ts +38 -3
- package/common/applicationConfig.ts +10 -0
- package/common/dev/inspection.ts +22 -0
- package/common/dev/mcpPayloads.ts +29 -0
- package/common/dev/proteumManifest.ts +1 -0
- package/docs/agent-routing.md +4 -0
- package/package.json +1 -1
- package/scripts/update-codex-agents.ts +2 -1
- package/tests/agents-utils.test.cjs +62 -0
- package/tests/mcp.test.cjs +94 -0
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
|
}
|
package/cli/commands/dev.ts
CHANGED
|
@@ -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({
|
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
|
-
|
|
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
|
-
|
|
301
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
},
|
package/cli/presentation/help.ts
CHANGED
|
@@ -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
|
}),
|
package/cli/utils/agents.ts
CHANGED
|
@@ -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 (
|
|
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 (
|
|
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
|
|
package/common/dev/inspection.ts
CHANGED
|
@@ -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];
|
package/docs/agent-routing.md
CHANGED
|
@@ -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.
|
|
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
|
+
});
|
package/tests/mcp.test.cjs
CHANGED
|
@@ -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-'));
|