proteum 2.3.0 → 2.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/AGENTS.md +8 -3
  2. package/README.md +20 -15
  3. package/agents/project/AGENTS.md +15 -10
  4. package/agents/project/DOCUMENTATION.md +1326 -0
  5. package/agents/project/app-root/AGENTS.md +2 -2
  6. package/agents/project/diagnostics.md +9 -8
  7. package/agents/project/root/AGENTS.md +14 -8
  8. package/agents/project/tests/AGENTS.md +1 -0
  9. package/cli/commands/dev.ts +148 -25
  10. package/cli/commands/diagnose.ts +2 -0
  11. package/cli/commands/explain.ts +38 -9
  12. package/cli/commands/mcp.ts +126 -9
  13. package/cli/commands/orient.ts +44 -17
  14. package/cli/commands/runtime.ts +100 -17
  15. package/cli/mcp/router.ts +1010 -0
  16. package/cli/presentation/commands.ts +34 -24
  17. package/cli/presentation/help.ts +1 -1
  18. package/cli/runtime/commands.ts +129 -21
  19. package/cli/runtime/devSessions.ts +328 -2
  20. package/cli/runtime/mcpDaemon.ts +288 -0
  21. package/cli/runtime/ports.ts +151 -0
  22. package/cli/utils/agents.ts +93 -17
  23. package/cli/utils/appRoots.ts +232 -0
  24. package/common/dev/diagnostics.ts +1 -1
  25. package/common/dev/inspection.ts +8 -1
  26. package/common/dev/mcpPayloads.ts +431 -17
  27. package/common/dev/mcpServer.ts +33 -0
  28. package/docs/agent-routing.md +32 -21
  29. package/docs/dev-commands.md +1 -1
  30. package/docs/dev-sessions.md +3 -1
  31. package/docs/diagnostics.md +21 -20
  32. package/docs/mcp.md +109 -52
  33. package/docs/migrate-from-2.1.3.md +3 -5
  34. package/docs/request-tracing.md +3 -3
  35. package/package.json +10 -3
  36. package/server/app/devMcp.ts +45 -0
  37. package/server/services/router/request/ip.test.cjs +0 -1
  38. package/tests/agents-utils.test.cjs +58 -3
  39. package/tests/cli-mcp-command.test.cjs +262 -0
  40. package/tests/codex-mcp-usage.test.cjs +307 -0
  41. package/tests/dev-sessions.test.cjs +113 -0
  42. package/tests/dev-transpile-watch.test.cjs +0 -1
  43. package/tests/eslint-rules.test.cjs +0 -1
  44. package/tests/inspection.test.cjs +0 -1
  45. package/tests/mcp.test.cjs +748 -2
  46. package/tests/router-cache-config.test.cjs +0 -1
  47. package/vitest.config.mjs +9 -0
  48. package/cli/mcp/provider.ts +0 -365
  49. package/cli/mcp/stdio.ts +0 -16
@@ -1,7 +1,7 @@
1
1
  import type { TDevConsoleLogLevel, TDevConsoleLogsResponse } from './console';
2
2
  import type { TDoctorResponse } from './diagnostics';
3
3
  import { buildExplainSummaryItems } from './diagnostics';
4
- import type { TDiagnoseResponse, TExplainOwnerResponse, TOrientResponse } from './inspection';
4
+ import { explainOwner, type TDiagnoseResponse, type TExplainOwnerResponse, type TOrientResponse } from './inspection';
5
5
  import type { TPerfRequestResponse, TPerfTopResponse } from './performance';
6
6
  import type { TProteumManifest } from './proteumManifest';
7
7
  import type { TRequestTrace } from './requestTrace';
@@ -38,6 +38,7 @@ type TNodeFs = {
38
38
 
39
39
  type TNodePath = {
40
40
  dirname: (filepath: string) => string;
41
+ isAbsolute: (filepath: string) => boolean;
41
42
  join: (...segments: string[]) => string;
42
43
  relative: (from: string, to: string) => string;
43
44
  resolve: (...segments: string[]) => string;
@@ -72,6 +73,95 @@ export const truncateForMcp = (value: string, max = maxTextLength) =>
72
73
 
73
74
  export const compactList = <TValue>(values: TValue[], limit: number) => values.slice(0, Math.max(0, limit));
74
75
 
76
+ export type TTriggeredInstructionRead = {
77
+ file: string;
78
+ reason: string;
79
+ };
80
+
81
+ const matchesInstructionTrigger = (query: string, pattern: RegExp) => pattern.test(query);
82
+
83
+ const resolveRootContractFallbackFile = (rootAgentsFile?: string) => {
84
+ if (fs === undefined || path === undefined || !rootAgentsFile || !fileExists(rootAgentsFile)) return undefined;
85
+
86
+ const content = fs.readFileSync(rootAgentsFile, 'utf8');
87
+ const match = content.match(/Root contract fallback:\s+(.+?)\s*$/m);
88
+ const candidate = match?.[1]?.trim();
89
+ if (!candidate) return undefined;
90
+
91
+ const filepath = path.isAbsolute(candidate) ? candidate : path.resolve(path.dirname(rootAgentsFile), candidate);
92
+ return fileExists(filepath) ? filepath : undefined;
93
+ };
94
+
95
+ export const resolveTriggeredInstructionReads = ({
96
+ codingStyle,
97
+ diagnostics,
98
+ documentation,
99
+ optimizations,
100
+ query,
101
+ rootAgentsFile,
102
+ }: {
103
+ codingStyle?: string;
104
+ diagnostics?: string;
105
+ documentation?: string;
106
+ optimizations?: string;
107
+ query: string;
108
+ rootAgentsFile?: string;
109
+ }) => {
110
+ const normalizedQuery = query.toLowerCase();
111
+ const reads = new Map<string, TTriggeredInstructionRead>();
112
+ const addRead = (file: string | undefined, reason: string) => {
113
+ if (!file || !fileExists(file) || reads.has(file)) return;
114
+ reads.set(file, { file, reason });
115
+ };
116
+ const rootContract = resolveRootContractFallbackFile(rootAgentsFile);
117
+ const looksLikeGitLifecycle = matchesInstructionTrigger(
118
+ normalizedQuery,
119
+ /\b(commit|stage|push)\b|\band commit\b|\bpr\b|pull[- ]requests?|git add|git commit/,
120
+ );
121
+ const looksLikeFinishLifecycle = matchesInstructionTrigger(
122
+ normalizedQuery,
123
+ /\b(finish|finishing|done|complete|completion|final|validate|validation|verify|verification)\b/,
124
+ );
125
+ const looksLikeRuntimeVisible = matchesInstructionTrigger(
126
+ normalizedQuery,
127
+ /\b(runtime|request-time|request time|router|ssr|browser-visible|browser visible|controller|diagnose|trace|perf|repro|reproduction|failing|error|bug)\b/,
128
+ );
129
+ const looksLikeImplementationEdit = matchesInstructionTrigger(
130
+ normalizedQuery,
131
+ /\b(implement|change|edit|update|modify|fix|add|remove|refactor|increase|decrease|code)\b/,
132
+ );
133
+ const looksLikeProductOrDocs = matchesInstructionTrigger(
134
+ normalizedQuery,
135
+ /\b(feature|product|business|acceptance|docs|documentation|ux|copy|onboarding|pricing|commercial|semantics)\b/,
136
+ );
137
+ const looksLikeOptimization = matchesInstructionTrigger(
138
+ normalizedQuery,
139
+ /\b(optimize|optimization|performance|package|dependency|build|bundle)\b/,
140
+ );
141
+
142
+ if (looksLikeGitLifecycle) {
143
+ addRead(rootContract, 'Git lifecycle trigger; read the canonical root contract before any git write.');
144
+ }
145
+ if (looksLikeFinishLifecycle) {
146
+ addRead(rootContract, 'Finish or verification trigger; read the canonical root lifecycle contract.');
147
+ }
148
+ if (looksLikeRuntimeVisible) {
149
+ addRead(rootContract, 'Runtime-visible behavior trigger; read the canonical root verification contract.');
150
+ addRead(diagnostics, 'Runtime, request, trace, perf, reproduction, or error trigger.');
151
+ }
152
+ if (looksLikeImplementationEdit) {
153
+ addRead(codingStyle, 'Implementation edit trigger; read coding style before editing.');
154
+ }
155
+ if (looksLikeProductOrDocs) {
156
+ addRead(documentation, 'Feature, product, business-rule, UX, copy, or docs trigger.');
157
+ }
158
+ if (looksLikeOptimization) {
159
+ addRead(optimizations, 'Package, build, runtime, performance, or optimization trigger.');
160
+ }
161
+
162
+ return [...reads.values()];
163
+ };
164
+
75
165
  export const createMcpPayload = <TData extends object>({
76
166
  data,
77
167
  nextActions,
@@ -178,6 +268,23 @@ export const compactOrientationResponse = (response: TOrientResponse) => {
178
268
  const summary = topOwner
179
269
  ? `${response.query} -> ${topOwner.kind} ${topOwner.label} (${topOwner.scopeLabel})`
180
270
  : `${response.query} -> no manifest owner matched`;
271
+ const topPath =
272
+ topOwner && (topOwner.kind === 'route' || topOwner.kind === 'controller') && topOwner.label.startsWith('/')
273
+ ? topOwner.label
274
+ : response.query.startsWith('/')
275
+ ? response.query
276
+ : undefined;
277
+ const triggered = resolveTriggeredInstructionReads({
278
+ codingStyle: response.guidance.codingStyle,
279
+ diagnostics: response.guidance.diagnostics,
280
+ documentation: response.guidance.documentation,
281
+ optimizations: response.guidance.optimizations,
282
+ query: response.normalizedQuery || response.query,
283
+ rootAgentsFile:
284
+ path !== undefined && response.app.repoRoot !== response.app.appRoot
285
+ ? path.join(response.app.repoRoot, 'AGENTS.md')
286
+ : response.guidance.agents,
287
+ });
181
288
 
182
289
  return createMcpPayload({
183
290
  summary,
@@ -190,8 +297,19 @@ export const compactOrientationResponse = (response: TOrientResponse) => {
190
297
  totalReturned: response.owner.matches.length,
191
298
  },
192
299
  instructions: {
193
- mustRead: [...new Set([response.guidance.agents, ...response.guidance.areaAgents])],
300
+ mustRead: [
301
+ ...new Set([
302
+ response.guidance.agents,
303
+ ...response.guidance.areaAgents,
304
+ ...triggered.map((entry) => entry.file),
305
+ ]),
306
+ ],
307
+ triggered,
194
308
  readWhen: [
309
+ {
310
+ file: response.guidance.documentation,
311
+ when: 'Non-trivial coding tasks that need the smallest `/docs` pack and post-change docs updates.',
312
+ },
195
313
  {
196
314
  file: response.guidance.diagnostics,
197
315
  when: 'Raw errors, failing requests, traces, perf regressions, or reproduction work.',
@@ -214,11 +332,39 @@ export const compactOrientationResponse = (response: TOrientResponse) => {
214
332
  },
215
333
  warnings: response.warnings,
216
334
  },
217
- nextActions: response.nextSteps.map((step) => ({
218
- command: step.command,
219
- label: step.label,
220
- reason: step.reason,
221
- })),
335
+ nextActions: [
336
+ ...(topOwner
337
+ ? [
338
+ {
339
+ label: 'Explain Summary',
340
+ tool: 'explain_summary',
341
+ toolArgs: { query: response.query },
342
+ reason: 'Use MCP owner summary before broad manifest or source searches.',
343
+ },
344
+ ]
345
+ : []),
346
+ ...(topPath
347
+ ? [
348
+ {
349
+ label: 'Diagnose Route',
350
+ tool: 'diagnose',
351
+ toolArgs: { path: topPath, query: response.query },
352
+ reason: 'Use the compact runtime diagnosis before CLI diagnose, raw traces, or browser work.',
353
+ },
354
+ {
355
+ label: 'Perf Request',
356
+ tool: 'perf_request',
357
+ toolArgs: { query: topPath },
358
+ reason: 'Use the compact request waterfall before raw perf detail.',
359
+ },
360
+ ]
361
+ : []),
362
+ ...response.nextSteps.map((step) => ({
363
+ command: step.command,
364
+ label: step.label,
365
+ reason: step.reason,
366
+ })),
367
+ ],
222
368
  });
223
369
  };
224
370
 
@@ -233,6 +379,12 @@ export const compactExplainSummary = ({
233
379
  }) => {
234
380
  if (owner) {
235
381
  const topOwner = owner.matches[0];
382
+ const topPath =
383
+ topOwner && (topOwner.kind === 'route' || topOwner.kind === 'controller') && topOwner.label.startsWith('/')
384
+ ? topOwner.label
385
+ : query && query.startsWith('/')
386
+ ? query
387
+ : undefined;
236
388
  return createMcpPayload({
237
389
  summary: topOwner
238
390
  ? `${query || owner.query} -> ${topOwner.kind} ${topOwner.label} (${topOwner.scopeLabel})`
@@ -247,6 +399,22 @@ export const compactExplainSummary = ({
247
399
  },
248
400
  manifest: summarizeManifest(manifest),
249
401
  },
402
+ nextActions: topPath
403
+ ? [
404
+ {
405
+ label: 'Diagnose Route',
406
+ tool: 'diagnose',
407
+ toolArgs: { path: topPath, query: query || owner.query },
408
+ reason: 'Use compact runtime diagnosis before CLI diagnose or raw trace detail.',
409
+ },
410
+ {
411
+ label: 'Perf Request',
412
+ tool: 'perf_request',
413
+ toolArgs: { query: topPath },
414
+ reason: 'Use compact request waterfall before raw perf detail.',
415
+ },
416
+ ]
417
+ : undefined,
250
418
  });
251
419
  }
252
420
 
@@ -320,6 +488,7 @@ export const compactDiagnoseResponse = (response: TDiagnoseResponse) => {
320
488
  instructions: response.orientation
321
489
  ? {
322
490
  mustRead: [...new Set([response.orientation.guidance.agents, ...response.orientation.guidance.areaAgents])],
491
+ documentation: response.orientation.guidance.documentation,
323
492
  diagnostics: response.orientation.guidance.diagnostics,
324
493
  codingStyle: response.orientation.guidance.codingStyle,
325
494
  optimizations: response.orientation.guidance.optimizations,
@@ -618,6 +787,28 @@ const resolveDocumentFile = ({
618
787
  return nearestRoot ? path.join(nearestRoot, relativeFilepath) : undefined;
619
788
  };
620
789
 
790
+ const fullInstructionReadPolicy = {
791
+ default: 'Use selected previews as the instruction source for read-only discovery and diagnostics.',
792
+ requiredWhen: [
793
+ 'editing files governed by the selected scope',
794
+ 'performing git writes such as stage, commit, push, or PR work',
795
+ 'changing schema, auth, runtime, generated contracts, or framework integration behavior',
796
+ 'the compact preview is insufficient for the current decision',
797
+ ],
798
+ };
799
+
800
+ const inferInstructionReadMode = (reason: string) =>
801
+ /git lifecycle|implementation edit|finish or verification|schema|migration/i.test(reason)
802
+ ? 'full-before-action'
803
+ : 'preview-first';
804
+
805
+ const createSelectedInstruction = (file: string, reason: string) => ({
806
+ file,
807
+ fullRead: inferInstructionReadMode(reason),
808
+ preview: readPreview(file),
809
+ reason,
810
+ });
811
+
621
812
  export const resolveInstructionRouting = ({
622
813
  appRoot,
623
814
  query = '',
@@ -627,7 +818,7 @@ export const resolveInstructionRouting = ({
627
818
  }) => {
628
819
  const normalizedQuery = query.trim();
629
820
  const repoRoot = findLikelyRepoRoot(appRoot);
630
- const selected = new Map<string, { file: string; reason: string; preview?: string }>();
821
+ const selected = new Map<string, ReturnType<typeof createSelectedInstruction>>();
631
822
  const readWhen: Array<{ file?: string; when: string }> = [];
632
823
  const addInstruction = (relativeFilepath: string, reason: string, preferAppRoot = true) => {
633
824
  if (path === undefined) return;
@@ -635,7 +826,7 @@ export const resolveInstructionRouting = ({
635
826
  for (const root of [...new Set(roots)]) {
636
827
  const filepath = path.join(root, relativeFilepath);
637
828
  if (!fileExists(filepath)) continue;
638
- selected.set(filepath, { file: filepath, reason, preview: readPreview(filepath) });
829
+ selected.set(filepath, createSelectedInstruction(filepath, reason));
639
830
  return;
640
831
  }
641
832
  };
@@ -644,14 +835,16 @@ export const resolveInstructionRouting = ({
644
835
  readWhen.push({ file: filepath, when });
645
836
  };
646
837
  const lowerQuery = normalizedQuery.toLowerCase();
647
- const looksLikePage = lowerQuery.startsWith('/') || lowerQuery.includes('client/pages') || lowerQuery.includes('.tsx');
838
+ const looksLikeRoutePath = /(^|\s)\/[a-z0-9_./:-]*/i.test(lowerQuery);
839
+ const looksLikePage = looksLikeRoutePath || lowerQuery.includes('client/pages') || lowerQuery.includes('.tsx');
648
840
  const looksLikeClient = looksLikePage || lowerQuery.includes('client/') || lowerQuery.includes('component') || lowerQuery.includes('island');
649
841
  const looksLikeServerRoute =
650
842
  lowerQuery.includes('server/routes') ||
651
843
  lowerQuery.includes('route') ||
652
844
  lowerQuery.includes('sitemap') ||
653
845
  lowerQuery.includes('rss') ||
654
- lowerQuery.startsWith('/api');
846
+ /^\/api(\/|$)/.test(lowerQuery) ||
847
+ /\s\/api(\/|$)/.test(lowerQuery);
655
848
  const looksLikeService =
656
849
  lowerQuery.includes('server/services') ||
657
850
  lowerQuery.includes('.controller') ||
@@ -670,6 +863,23 @@ export const resolveInstructionRouting = ({
670
863
  addInstruction('tests/e2e/REAL_WORLD_JOURNEY_TESTS.md', 'Real-world journey coverage may be in scope.');
671
864
  }
672
865
 
866
+ const appAgentsFile = resolveDocumentFile({ appRoot, repoRoot, relativeFilepath: 'AGENTS.md' });
867
+ const repoAgentsFile = path !== undefined && repoRoot !== appRoot ? path.join(repoRoot, 'AGENTS.md') : undefined;
868
+ for (const triggered of resolveTriggeredInstructionReads({
869
+ codingStyle: resolveDocumentFile({ appRoot, repoRoot, relativeFilepath: 'CODING_STYLE.md' }),
870
+ diagnostics: resolveDocumentFile({ appRoot, repoRoot, relativeFilepath: 'diagnostics.md' }),
871
+ documentation: resolveDocumentFile({ appRoot, repoRoot, relativeFilepath: 'DOCUMENTATION.md' }),
872
+ optimizations: resolveDocumentFile({ appRoot, repoRoot, relativeFilepath: 'optimizations.md' }),
873
+ query: normalizedQuery,
874
+ rootAgentsFile: repoAgentsFile && fileExists(repoAgentsFile) ? repoAgentsFile : appAgentsFile,
875
+ })) {
876
+ selected.set(triggered.file, createSelectedInstruction(triggered.file, triggered.reason));
877
+ }
878
+
879
+ addReadWhen(
880
+ 'DOCUMENTATION.md',
881
+ 'Read before non-trivial coding tasks to choose the smallest `/docs` pack and update docs after changes.',
882
+ );
673
883
  addReadWhen('diagnostics.md', 'Read for raw errors, failing requests, traces, perf regressions, or reproduction work.');
674
884
  addReadWhen('CODING_STYLE.md', 'Read before editing implementation files.');
675
885
  addReadWhen('optimizations.md', 'Read for client-side implementation, packages, build, runtime, or performance work.');
@@ -683,6 +893,7 @@ export const resolveInstructionRouting = ({
683
893
  repoRoot,
684
894
  selected: selectedFiles,
685
895
  readWhen,
896
+ fullReadPolicy: fullInstructionReadPolicy,
686
897
  missingRuntime:
687
898
  selectedFiles.length === 0
688
899
  ? 'No tracked instruction files were found. Run `proteum configure agents` or start `proteum dev` to refresh managed instructions.'
@@ -691,6 +902,213 @@ export const resolveInstructionRouting = ({
691
902
  });
692
903
  };
693
904
 
905
+ const chooseWorkflowOwnerQuery = ({
906
+ file,
907
+ query,
908
+ route,
909
+ }: {
910
+ file?: string;
911
+ query?: string;
912
+ route?: string;
913
+ }) => [route, file, query].map((value) => value?.trim()).find((value): value is string => Boolean(value));
914
+
915
+ const chooseWorkflowInstructionQuery = ({
916
+ file,
917
+ query,
918
+ route,
919
+ task,
920
+ }: {
921
+ file?: string;
922
+ query?: string;
923
+ route?: string;
924
+ task?: string;
925
+ }) =>
926
+ [task, query, route, file]
927
+ .map((value) => value?.trim())
928
+ .filter((value): value is string => Boolean(value))
929
+ .join(' ');
930
+
931
+ const isReachableHealth = (health: object | undefined) => {
932
+ if (!health || !('reachable' in health)) return true;
933
+
934
+ return (health as { reachable?: unknown }).reachable === true;
935
+ };
936
+
937
+ const createRuntimeDownNextAction = () => ({
938
+ label: 'Start Dev',
939
+ command: 'proteum dev --session-file var/run/proteum/dev/agents/<task>.json --port <free-port>',
940
+ reason: 'Runtime is not reachable; start or repair one tracked dev session before diagnose, trace, or perf reads.',
941
+ });
942
+
943
+ export const compactWorkflowStartResponse = ({
944
+ contracts,
945
+ doctor,
946
+ file,
947
+ health,
948
+ manifest,
949
+ owner,
950
+ query,
951
+ route,
952
+ runtime,
953
+ task,
954
+ }: {
955
+ contracts: TDoctorResponse;
956
+ doctor: TDoctorResponse;
957
+ file?: string;
958
+ health?: object;
959
+ manifest: TProteumManifest;
960
+ owner?: TExplainOwnerResponse;
961
+ query?: string;
962
+ route?: string;
963
+ runtime?: object;
964
+ task?: string;
965
+ }) => {
966
+ const ownerQuery = chooseWorkflowOwnerQuery({ file, query, route });
967
+ const instructionQuery = chooseWorkflowInstructionQuery({ file, query, route, task });
968
+ const instructions = resolveInstructionRouting({
969
+ appRoot: manifest.app.root,
970
+ query: instructionQuery,
971
+ });
972
+ const topOwner = owner?.matches[0];
973
+ const topPath =
974
+ topOwner && (topOwner.kind === 'route' || topOwner.kind === 'controller') && topOwner.label.startsWith('/')
975
+ ? topOwner.label
976
+ : route && route.startsWith('/')
977
+ ? route
978
+ : ownerQuery && ownerQuery.startsWith('/')
979
+ ? ownerQuery
980
+ : undefined;
981
+ const runtimeReachable = isReachableHealth(health);
982
+
983
+ return createMcpPayload({
984
+ summary: `${manifest.app.identity.identifier}: workflow start${ownerQuery ? ` for ${ownerQuery}` : ''}; ${instructions.data.selected.length} instruction file${instructions.data.selected.length === 1 ? '' : 's'}`,
985
+ data: {
986
+ workflow: {
987
+ task: task?.trim() || undefined,
988
+ query: ownerQuery,
989
+ route: route?.trim() || undefined,
990
+ file: file?.trim() || undefined,
991
+ },
992
+ runtime: {
993
+ appRoot: manifest.app.root,
994
+ manifest: summarizeManifest(manifest),
995
+ runtime,
996
+ health,
997
+ },
998
+ instructions: {
999
+ selected: compactList(instructions.data.selected, 8),
1000
+ readWhen: compactList(instructions.data.readWhen, 6),
1001
+ fullReadPolicy: fullInstructionReadPolicy,
1002
+ totalSelected: instructions.data.selected.length,
1003
+ },
1004
+ owner: owner
1005
+ ? {
1006
+ query: owner.query,
1007
+ normalizedQuery: owner.normalizedQuery,
1008
+ top: topOwner ? compactOwnerMatch(topOwner) : undefined,
1009
+ matches: compactList(owner.matches, 5).map(compactOwnerMatch),
1010
+ totalReturned: owner.matches.length,
1011
+ }
1012
+ : undefined,
1013
+ diagnostics: {
1014
+ doctor: doctor.summary,
1015
+ contracts: contracts.summary,
1016
+ },
1017
+ duplicateAvoidance: [
1018
+ 'If owner.top resolves a route or file, do not run broad source searches for the same owner.',
1019
+ 'If this runtime block is present, do not run CLI runtime status for the same app.',
1020
+ 'If diagnose succeeds for this path or request, do not rerun CLI diagnose for the same read.',
1021
+ 'Open full traces, logs, or instruction files only when compact output says the omitted detail is needed.',
1022
+ ],
1023
+ },
1024
+ nextActions: [
1025
+ ...(!runtimeReachable ? [createRuntimeDownNextAction()] : []),
1026
+ ...(topPath && runtimeReachable
1027
+ ? [
1028
+ {
1029
+ label: 'Diagnose Route',
1030
+ tool: 'diagnose',
1031
+ toolArgs: { path: topPath, query: ownerQuery || topPath },
1032
+ reason: 'Use compact runtime diagnosis before CLI diagnose, raw traces, browser work, or broad source search.',
1033
+ },
1034
+ {
1035
+ label: 'Perf Request',
1036
+ tool: 'perf_request',
1037
+ toolArgs: { query: topPath },
1038
+ reason: 'Use the compact request waterfall before raw perf detail.',
1039
+ },
1040
+ ]
1041
+ : []),
1042
+ ...(!ownerQuery && instructionQuery
1043
+ ? [
1044
+ {
1045
+ label: 'Orient Query',
1046
+ tool: 'orient',
1047
+ toolArgs: { query: instructionQuery },
1048
+ reason: 'Use MCP orientation only if the workflow bootstrap did not include a concrete owner query.',
1049
+ },
1050
+ ]
1051
+ : []),
1052
+ ],
1053
+ omitted: [
1054
+ {
1055
+ reason: 'Full instruction files are omitted. Use selected previews for read-only work; read full files only when the fullReadPolicy requires it.',
1056
+ tool: 'instructions_resolve',
1057
+ toolArgs: { query: instructionQuery },
1058
+ },
1059
+ ],
1060
+ });
1061
+ };
1062
+
1063
+ export const compactRouteCandidatesResponse = ({
1064
+ limit = 8,
1065
+ manifest,
1066
+ query,
1067
+ }: {
1068
+ limit?: number;
1069
+ manifest: TProteumManifest;
1070
+ query: string;
1071
+ }) => {
1072
+ const owner = explainOwner(manifest, query);
1073
+ const routeMatches = owner.matches.filter((match) => match.kind === 'route');
1074
+
1075
+ return createMcpPayload({
1076
+ summary:
1077
+ routeMatches.length === 0
1078
+ ? `${query} -> no route candidates`
1079
+ : `${query} -> ${routeMatches.length} route candidate${routeMatches.length === 1 ? '' : 's'}`,
1080
+ data: {
1081
+ query,
1082
+ normalizedQuery: owner.normalizedQuery,
1083
+ candidates: compactList(routeMatches, limit).map(compactOwnerMatch),
1084
+ returned: Math.min(routeMatches.length, limit),
1085
+ totalMatches: routeMatches.length,
1086
+ manifest: summarizeManifest(manifest),
1087
+ },
1088
+ nextActions:
1089
+ routeMatches.length > 0
1090
+ ? [
1091
+ {
1092
+ label: 'Explain Top Route',
1093
+ tool: 'explain_summary',
1094
+ toolArgs: { query: routeMatches[0].label },
1095
+ reason: 'Inspect the top route owner without dumping raw route arrays.',
1096
+ },
1097
+ ]
1098
+ : undefined,
1099
+ omitted:
1100
+ routeMatches.length > limit
1101
+ ? [
1102
+ {
1103
+ reason: `Route candidates are capped at ${limit}. Refine the query before requesting raw route arrays.`,
1104
+ tool: 'route_candidates',
1105
+ toolArgs: { query, limit: Math.min(50, limit * 2) },
1106
+ },
1107
+ ]
1108
+ : undefined,
1109
+ });
1110
+ };
1111
+
694
1112
  export const buildRuntimeStatusPayload = ({
695
1113
  appRoot,
696
1114
  health,
@@ -717,7 +1135,7 @@ export const buildRuntimeStatusPayload = ({
717
1135
  sessions,
718
1136
  health,
719
1137
  },
720
- nextActions: runtime
1138
+ nextActions: runtime && isReachableHealth(health)
721
1139
  ? [
722
1140
  {
723
1141
  label: 'Diagnose Root',
@@ -727,10 +1145,6 @@ export const buildRuntimeStatusPayload = ({
727
1145
  },
728
1146
  ]
729
1147
  : [
730
- {
731
- label: 'Start Dev',
732
- command: 'proteum dev --session-file var/run/proteum/dev/agents/<task>.json --port <free-port>',
733
- reason: 'Create a tracked dev session before request-time diagnostics.',
734
- },
1148
+ createRuntimeDownNextAction(),
735
1149
  ],
736
1150
  });
@@ -22,9 +22,11 @@ export type TProteumMcpProvider = {
22
22
  perfRequest: (input: { query: string }) => Promise<TProteumMcpPayload>;
23
23
  perfTop: (input: { groupBy?: 'path' | 'route' | 'controller'; limit?: number; since?: string }) => Promise<TProteumMcpPayload>;
24
24
  readResource: (uri: string) => Promise<TProteumMcpPayload>;
25
+ routeCandidates: (input: { limit?: number; query: string }) => Promise<TProteumMcpPayload>;
25
26
  runtimeStatus: (input: Record<string, never>) => Promise<TProteumMcpPayload>;
26
27
  traceLatest: (input: { detail?: TProteumMcpDetail; limit?: number; offset?: number }) => Promise<TProteumMcpPayload>;
27
28
  traceShow: (input: { detail?: TProteumMcpDetail; limit?: number; offset?: number; requestId: string }) => Promise<TProteumMcpPayload>;
29
+ workflowStart: (input: { file?: string; query?: string; route?: string; task?: string }) => Promise<TProteumMcpPayload>;
28
30
  };
29
31
 
30
32
  type TCreateProteumMcpServerArgs = {
@@ -76,6 +78,23 @@ export const createProteumMcpServer = ({ provider, version }: TCreateProteumMcpS
76
78
  },
77
79
  );
78
80
 
81
+ server.registerTool(
82
+ 'workflow_start',
83
+ {
84
+ annotations: readOnlyAnnotations,
85
+ description:
86
+ 'Bootstrap an agent workflow with compact runtime, instruction, owner, doctor, and next-action data in one read.',
87
+ inputSchema: {
88
+ file: z.string().optional().describe('Optional source file or generated artifact path in scope.'),
89
+ query: z.string().optional().describe('Optional task, route, controller, file, or owner query.'),
90
+ route: z.string().optional().describe('Optional route path in scope.'),
91
+ task: z.string().optional().describe('Optional short natural-language task description.'),
92
+ },
93
+ title: 'Proteum Workflow Start',
94
+ },
95
+ async ({ file, query, route, task }) => jsonToolResult(await provider.workflowStart({ file, query, route, task })),
96
+ );
97
+
79
98
  server.registerTool(
80
99
  'runtime_status',
81
100
  {
@@ -126,6 +145,20 @@ export const createProteumMcpServer = ({ provider, version }: TCreateProteumMcpS
126
145
  async ({ query }) => jsonToolResult(await provider.explainSummary({ query })),
127
146
  );
128
147
 
148
+ server.registerTool(
149
+ 'route_candidates',
150
+ {
151
+ annotations: readOnlyAnnotations,
152
+ description: 'Return compact route candidates for a query without dumping raw route arrays.',
153
+ inputSchema: {
154
+ limit: z.number().int().min(1).max(50).optional(),
155
+ query: z.string().min(1).describe('Route path or route-like search query.'),
156
+ },
157
+ title: 'Proteum Route Candidates',
158
+ },
159
+ async ({ limit, query }) => jsonToolResult(await provider.routeCandidates({ limit, query })),
160
+ );
161
+
129
162
  server.registerTool(
130
163
  'doctor',
131
164
  {